Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/_docset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ toc:
- file: diff-validate.md
- file: format.md
- file: index-command.md
- file: list-dependents.md
- file: mv.md
- file: serve.md
- folder: assembler
Expand Down
1 change: 1 addition & 0 deletions docs/cli/docset/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ locate the `docset.yml` anywhere in the directory tree automatically and build t

- [mv](mv.md) - move a file or folder to a new location. This will rewrite all links in all files too.
- [diff validate](diff-validate.md) - validate that local changes are reflected in [redirects.yml](../../contribute/redirects.md)
- [list-dependents](list-dependents.md) - list the pages that transitively include given snippet or CSV files

63 changes: 63 additions & 0 deletions docs/cli/docset/list-dependents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# list-dependents

Resolves the markdown pages that transitively include the given files.

A page-level dependent is any non-snippet markdown file that — directly or through a
chain of `_snippets/`-to-`_snippets/` includes — pulls in the input file via an
`{{include}}` or `{{csv-include}}` directive.

This is intended for the docs preview workflow: when a PR only edits snippets or
CSV data, the preview comment would otherwise have no specific pages to link to.
Feeding the changed files through `list-dependents` produces the list of pages
that would re-render, so the comment can point at them instead.

## Usage

```bash
docs-builder list-dependents --files <paths> [options...] [-h|--help] [--version]
```

## Options

`--files <string>`
: Comma-separated list of file paths to resolve dependents for. Paths can be
git-relative (for example `docs/_snippets/foo.md`) or absolute.

`-p|--path <string>`
: Defaults to the `{pwd}/docs` folder (optional)

`--format <string>`
: Output format: `json` (default) or `text`.

## Output

`json` (default) emits a single object on stdout:

```json
{
"results": [
{
"input": "docs/_snippets/applies-switch.md",
"resolved": "_snippets/applies-switch.md",
"found": true,
"reason": null,
"dependents": [
"testing/index.md"
]
},
{
"input": "docs/_snippets/missing.md",
"resolved": "_snippets/missing.md",
"found": false,
"reason": "no consumers found",
"dependents": []
}
]
}
```

`text` emits a human-readable summary, one input per block.

An entry with `found: false` means either the file has no consumers in the
documentation set, or its path resolves outside the documentation source
directory.
123 changes: 123 additions & 0 deletions src/Elastic.Markdown/IO/IncludeGraph.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;
using System.Collections.Frozen;
using Elastic.Markdown.Myst.Directives.CsvInclude;
using Elastic.Markdown.Myst.Directives.Include;
using Markdig.Syntax;

namespace Elastic.Markdown.IO;

/// <summary>
/// Reverse-dependency lookup over <c>{{include}}</c> and <c>{{csv-include}}</c> directives
/// across a <see cref="DocumentationSet"/>. Resolves "which pages would re-render if this
/// snippet/CSV changes?" — used by the preview workflow to surface meaningful preview URLs
/// when only non-<c>.md</c> files (or files under <c>_snippets/</c>) are edited.
/// </summary>
public sealed class IncludeGraph
{
private readonly FrozenDictionary<string, FrozenSet<string>> _consumersByTarget;
private readonly FrozenSet<string> _snippetPaths;

private IncludeGraph(
FrozenDictionary<string, FrozenSet<string>> consumersByTarget,
FrozenSet<string> snippetPaths)
{
_consumersByTarget = consumersByTarget;
_snippetPaths = snippetPaths;
}

public static async Task<IncludeGraph> BuildAsync(DocumentationSet set, Cancel ctx)
{
var edges = new ConcurrentBag<(string Target, string Consumer)>();

// Pages live in DocumentationSet.MarkdownFiles; snippets are stored separately in
// DocumentationSet.Files as SnippetFile records. Both can host {{include}} directives,
// so we parse both to capture page→snippet AND snippet→snippet edges.
var pages = set.MarkdownFiles.Cast<DocumentationFile>();
var snippets = set.Files.Values.OfType<SnippetFile>().Cast<DocumentationFile>();

await Parallel.ForEachAsync(pages.Concat(snippets), ctx, async (file, token) =>
{
var document = file switch
{
MarkdownFile md => await md.MinimalParseAsync(set.TryFindDocumentByRelativePath, token),
SnippetFile sn => await set.MarkdownParser.MinimalParseAsync(sn.SourceFile, token),
_ => null
};
if (document is null)
return;

var consumer = NormalizePath(file.RelativePath);

foreach (var include in document.Descendants<IncludeBlock>())
{
if (include is { Found: true, IncludePathRelativeToSource: { } target })
edges.Add((NormalizePath(target), consumer));
}

foreach (var csv in document.Descendants<CsvIncludeBlock>())
{
if (csv is { Found: true, CsvFilePathRelativeToSource: { } target })
edges.Add((NormalizePath(target), consumer));
}
}).ConfigureAwait(false);

var consumersByTarget = edges
.GroupBy(e => e.Target, StringComparer.OrdinalIgnoreCase)
.ToFrozenDictionary(
g => g.Key,
g => g.Select(e => e.Consumer).ToFrozenSet(StringComparer.OrdinalIgnoreCase),
StringComparer.OrdinalIgnoreCase);

var snippetPaths = set.Files.Values
.OfType<SnippetFile>()
.Select(f => NormalizePath(f.RelativePath))
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);

return new IncludeGraph(consumersByTarget, snippetPaths);
}

/// <summary>
/// Walks consumers of <paramref name="targetRelativePath"/> upward through any intermediate
/// snippet-includes-snippet hops and returns the set of *page-level* dependents (non-snippet
/// markdown files). The input itself is not included in the result.
/// </summary>
public IReadOnlySet<string> ResolvePageDependents(string targetRelativePath)
{
var pages = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var queue = new Queue<string>();
queue.Enqueue(NormalizePath(targetRelativePath));

while (queue.Count > 0)
{
var current = queue.Dequeue();
if (!_consumersByTarget.TryGetValue(current, out var consumers))
continue;

foreach (var consumer in consumers)
{
if (!seen.Add(consumer))
continue;
if (_snippetPaths.Contains(consumer))
queue.Enqueue(consumer);
else
_ = pages.Add(consumer);
}
}

return pages;
}

/// <summary>
/// Returns true if the graph has at least one consumer recorded for the given target.
/// Lets callers distinguish "snippet exists but is unused" from "snippet path unknown".
/// </summary>
public bool HasConsumers(string targetRelativePath) =>
_consumersByTarget.ContainsKey(NormalizePath(targetRelativePath));

private static string NormalizePath(string path) => path.Replace('\\', '/');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Text.Json;
using System.Text.Json.Serialization;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Extensions;
using Elastic.Documentation.Links.CrossLinks;
using Elastic.Documentation.Services;
using Elastic.Markdown.IO;
using Microsoft.Extensions.Logging;
using Nullean.ScopedFileSystem;

namespace Elastic.Documentation.Refactor.Tracking;

public class ListDependentsService(
ILoggerFactory logFactory,
IConfigurationContext configurationContext
) : IService
{
private readonly ILogger _logger = logFactory.CreateLogger<ListDependentsService>();

public async Task<bool> ListDependents(
IDiagnosticsCollector collector,
ScopedFileSystem fs,
string? path,
IReadOnlyList<string> files,
string format,
Cancel ctx)
{
if (files.Count == 0)
{
collector.EmitGlobalError("list-dependents requires at least one file argument.");
return false;
}

if (!format.Equals("json", StringComparison.OrdinalIgnoreCase) &&
!format.Equals("text", StringComparison.OrdinalIgnoreCase))
{
collector.EmitGlobalError($"Unsupported format '{format}'. Expected 'json' or 'text'.");
return false;
}

var context = new BuildContext(collector, fs, fs, configurationContext, ExportOptions.MetadataOnly, path, null);
var set = new DocumentationSet(context, logFactory, NoopCrossLinkResolver.Instance);

var graph = await IncludeGraph.BuildAsync(set, ctx);

var sourceDir = context.DocumentationSourceDirectory;
var gitRoot = Paths.FindGitRoot(sourceDir);

var results = files
.Select(input => ResolveOne(input, sourceDir.FullName, gitRoot?.FullName, graph))
.ToArray();

var output = format.Equals("text", StringComparison.OrdinalIgnoreCase)
? RenderText(results)
: RenderJson(results);
Console.Out.WriteLine(output);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

var totalDependents = results.Sum(r => r.Dependents.Count);
_logger.LogInformation(
"Resolved {Inputs} input(s) → {Pages} page dependents", results.Length, totalDependents);
return true;
Comment on lines +62 to +67

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list-dependents is documented as emitting JSON (default), but the CLI pipeline will also write non-JSON content to stdout (e.g., this service’s LogInformation plus the tooling’s console diagnostics summary printed at shutdown). With the current logging/diagnostics setup, stdout will not be valid JSON for downstream consumers. Consider isolating machine output (e.g., route logs/diagnostics to stderr, suppress them for --format json, or add a dedicated --quiet/--no-diagnostics mode and document it).

Copilot uses AI. Check for mistakes.
}

private static DependentsResult ResolveOne(string input, string sourceDirFullName, string? gitRootFullName, IncludeGraph graph)
{
var absolute = Path.IsPathRooted(input)
? input
: gitRootFullName is not null
? Path.GetFullPath(Path.Combine(gitRootFullName, input))
: Path.GetFullPath(input);

var sourceRelative = Path.GetRelativePath(sourceDirFullName, absolute);
if (sourceRelative.StartsWith("..", StringComparison.Ordinal))
{
return new DependentsResult(input, sourceRelative.OptionalWindowsReplace(), false, "outside documentation source", []);
}

var normalized = sourceRelative.Replace('\\', '/');
if (!graph.HasConsumers(normalized))
return new DependentsResult(input, normalized, false, "no consumers found", []);

var dependents = graph.ResolvePageDependents(normalized).OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToArray();
return new DependentsResult(input, normalized, true, null, dependents);
}

private static string RenderJson(IReadOnlyList<DependentsResult> results)
{
var payload = new DependentsPayload(results);
return JsonSerializer.Serialize(payload, ListDependentsJsonContext.Default.DependentsPayload);
}

private static string RenderText(IReadOnlyList<DependentsResult> results)
{
var sb = new System.Text.StringBuilder();
foreach (var r in results)
{
if (!r.Found)
{
_ = sb.Append(r.Input).Append(" → no dependents (").Append(r.Reason ?? "unknown").AppendLine(")");
continue;
}
_ = sb.Append(r.Input).Append(" → ").Append(r.Dependents.Count).AppendLine(" dependent page(s):");
foreach (var dep in r.Dependents)
_ = sb.Append(" ").AppendLine(dep);
}
return sb.ToString().TrimEnd();
}
}

public sealed record DependentsPayload(
[property: JsonPropertyName("results")] IReadOnlyList<DependentsResult> Results);

public sealed record DependentsResult(
[property: JsonPropertyName("input")] string Input,
[property: JsonPropertyName("resolved")] string Resolved,
[property: JsonPropertyName("found")] bool Found,
[property: JsonPropertyName("reason")] string? Reason,
[property: JsonPropertyName("dependents")] IReadOnlyList<string> Dependents);

[JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(DependentsPayload))]
internal sealed partial class ListDependentsJsonContext : JsonSerializerContext;
51 changes: 51 additions & 0 deletions src/tooling/docs-builder/Commands/ListDependentsCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.IO.Abstractions;
using ConsoleAppFramework;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Refactor.Tracking;
using Elastic.Documentation.Services;
using Microsoft.Extensions.Logging;

namespace Documentation.Builder.Commands;

internal sealed class ListDependentsCommand(
ILoggerFactory logFactory,
IDiagnosticsCollector collector,
IConfigurationContext configurationContext
)
{
/// <summary>
/// Lists the markdown pages that transitively include the given files (snippets, CSVs, or
/// other inputs to {{include}} / {{csv-include}} directives). Used by the docs preview
/// workflow so a PR that only edits non-page files still gets a preview URL pointing at
/// the pages that would re-render.
/// </summary>
/// <param name="files">Comma-separated list of file paths (git-relative or absolute) to resolve dependents for.</param>
/// <param name="path"> -p, Defaults to the `{pwd}/docs` folder</param>
/// <param name="format">Output format: 'json' (default) or 'text'.</param>
/// <param name="ctx"></param>
[Command("")]
public async Task<int> ListDependents(
string files,
string? path = null,
string format = "json",
Cancel ctx = default
)
{
await using var serviceInvoker = new ServiceInvoker(collector);

var fileList = files.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var service = new ListDependentsService(logFactory, configurationContext);
var fs = FileSystemFactory.RealGitRootForPath(path);

serviceInvoker.AddCommand(service, (path, fs, fileList, format),
async static (s, collector, state, ctx) => await s.ListDependents(
collector, state.fs, state.path, state.fileList, state.format, ctx)
);
return await serviceInvoker.InvokeAsync(ctx);
}
}
1 change: 1 addition & 0 deletions src/tooling/docs-builder/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
app.Add<IndexCommand>("index");
app.Add<FormatCommand>("format");
app.Add<ChangelogCommand>("changelog");
app.Add<ListDependentsCommand>("list-dependents");

//assembler commands

Expand Down
Loading
Loading