@catheadowl/ukp 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,61 @@
1
- import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { realpathSync, statSync } from "node:fs";
2
2
  import { join, relative, resolve } from "node:path";
3
+ import {
4
+ DEFAULT_EXCLUDE_DIRS,
5
+ DEFAULT_EXCLUDE_FILES,
6
+ builtinDescriptionProvider,
7
+ scanStructure,
8
+ type NavNode,
9
+ type ResolvedScanOptions,
10
+ type ScanDiagnostic,
11
+ } from "@any-nav/scan";
3
12
  import { loadManifest, type ManifestCapability } from "../config/manifest.ts";
4
13
  import { resolveFileNativeCapability, unsupportedFileNativeProviderMessage } from "../config/file-native.ts";
5
14
  import { localPathOf, readRegistry } from "../registry.ts";
6
15
  import { resolveScope } from "../scope.ts";
7
16
  import { isInsideRealRoot, splitEndpointRelativeSegments } from "../path-safety.ts";
8
17
 
9
- // Nav capability: UKP-native file provider that
10
- // enumerates the Markdown structure of one endpoint. The design stance is
11
- // provider-owned visibility: the exclusion rules
12
- // below are the file provider's own visibility contract — the caller can
13
- // never override them from the command surface (path/depth/format only).
14
-
15
- /** Default directory exclusions (case-insensitive), aligned with the
16
- * reference scanner (any_nav): build-tool output trees carry no knowledge
17
- * routes. Services replace the whole set via
18
- * `[capabilities.nav.config] exclude_dirs = [...]`. */
19
- export const NAV_DEFAULT_EXCLUDE_DIRS: readonly string[] = [
20
- "node_modules",
21
- "dist",
22
- "build",
23
- "lib",
24
- "out",
25
- "coverage",
26
- ];
27
-
28
- /** Default file-name exclusions (case-insensitive). Agent-instruction files
29
- * (AGENTS.md / CLAUDE.md) are context the agent harness already injects —
30
- * they are not discoverable knowledge for nav's goal, so they never appear
31
- * in route views nor truncated counts. Replaceable via
32
- * `[capabilities.nav.config] exclude_files = [...]`. */
33
- export const NAV_DEFAULT_EXCLUDE_FILES: readonly string[] = ["AGENTS.md", "CLAUDE.md"];
34
-
35
- /** The provider-side visibility set resolved from the capability declaration:
36
- * defaults unless the Service overrides a whole list in its own config. */
37
- export interface NavVisibility {
38
- excludeDirs: readonly string[];
39
- excludeFiles: readonly string[];
18
+ // Nav capability: file provider whose scan engine is delegated to
19
+ // `@any-nav/scan` (W2 / N3 re-ruling, 2026-10-22). The design stance is
20
+ // provider-owned visibility: the exclusion rules below are the file
21
+ // provider's own visibility contract — the caller can never override them
22
+ // from the command surface (path/depth/format only). The engine owns the
23
+ // walk (gitignore inheritance, dot/exclusion floors, .md-only pruning,
24
+ // budget counting); UKP owns addressing, the route-root/depth projection,
25
+ // the entry window and the ukp.nav.v1 envelope.
26
+
27
+ /** Safety cap on listed route entries (owner R6 Q1 = A1, 2026-10-22): the
28
+ * structure walk sees everything, then this UKP projection window cuts —
29
+ * loud via `max-entries-reached`, same diagnostic shape as the native
30
+ * implementation, agent contract unchanged. */
31
+ export const NAV_MAX_ENTRIES = 2000;
32
+
33
+ /** Terminal scan budget for the structure walk (owner R6 Q2 = B1,
34
+ * 2026-10-22): `ResolvedScanOptions.maxFiles` is a required number with no
35
+ * "unlimited" sentinel (≤0 caps instantly), so a high terminal value is the
36
+ * practical-unbounded form — counts stay exact on every realistic corpus.
37
+ * If it ever binds, the engine's consumer diagnostics disclose the floor
38
+ * counts (never a silent cut). */
39
+ export const NAV_SCAN_MAX_FILES = 1_000_000;
40
+
41
+ /** Provider-side scan options resolved from the capability declaration:
42
+ * engine terminal values (whole-set replacement — R4/R5), dot entries and
43
+ * gitignore always on, budget per NAV_SCAN_MAX_FILES. */
44
+ export function resolveNavScanOptions(capability: ManifestCapability): ResolvedScanOptions {
45
+ const config = capability.config ?? {};
46
+ if (Object.hasOwn(config, "max_description_files")) {
47
+ throw new NavProviderError(
48
+ "nav config 'max_description_files' is retired (owner R6 Q3 = C1, 2026-10-22): "
49
+ + "the description budget collapsed into the entry window — remove the key",
50
+ );
51
+ }
52
+ return {
53
+ excludeDirs: parseStringList(config, "exclude_dirs") ?? DEFAULT_EXCLUDE_DIRS,
54
+ excludeFiles: parseStringList(config, "exclude_files") ?? DEFAULT_EXCLUDE_FILES,
55
+ excludeDotEntries: true,
56
+ respectGitignore: true,
57
+ maxFiles: NAV_SCAN_MAX_FILES,
58
+ };
40
59
  }
41
60
 
42
61
  function parseStringList(config: Record<string, unknown>, key: string): string[] | undefined {
@@ -45,177 +64,7 @@ function parseStringList(config: Record<string, unknown>, key: string): string[]
45
64
  if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string" || item.length === 0)) {
46
65
  throw new NavProviderError(`nav config '${key}' must be an array of non-empty strings`);
47
66
  }
48
- return (raw as string[]).map((item) => item.toLowerCase());
49
- }
50
-
51
- export function resolveNavVisibility(capability: ManifestCapability): NavVisibility {
52
- const config = capability.config ?? {};
53
- return {
54
- excludeDirs: parseStringList(config, "exclude_dirs") ?? NAV_DEFAULT_EXCLUDE_DIRS.map((d) => d.toLowerCase()),
55
- excludeFiles: parseStringList(config, "exclude_files") ?? NAV_DEFAULT_EXCLUDE_FILES.map((f) => f.toLowerCase()),
56
- };
57
- }
58
-
59
- /** Safety cap on collected route entries; the recursive truncation count
60
- * still walks everything so the `(+N .md)` totals stay exact. */
61
- export const NAV_MAX_ENTRIES = 2000;
62
-
63
- /** Budget on per-entry description reads (ADR 0018): the expensive
64
- * cost class gets a configurable cap. Configurable by precedent (VS Code
65
- * maxResults, TS/VS Code "let the user decide what to skip"); loud by
66
- * contract when hit (never a silent drop). Counts stay exact and unbounded —
67
- * budgets are for expensive-and-droppable work, never cheap-and-load-bearing
68
- * work. Default equals the entry cap, so the budget only binds when a
69
- * Service deliberately lowers it. */
70
- export const NAV_DEFAULT_MAX_DESCRIPTION_FILES = NAV_MAX_ENTRIES;
71
-
72
- function resolveNavDescriptionBudget(capability: ManifestCapability): number {
73
- const raw = (capability.config ?? {})["max_description_files"];
74
- if (raw === undefined) return NAV_DEFAULT_MAX_DESCRIPTION_FILES;
75
- if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 1) {
76
- throw new NavProviderError("nav config 'max_description_files' must be a positive integer");
77
- }
78
- return raw;
79
- }
80
-
81
- // ---------------------------------------------------------------------------
82
- // .gitignore support (visibility follows the endpoint's own ignore files —
83
- // zero UKP-side configuration). Semantics are the git subset: comments,
84
- // blank lines, `!` negation, trailing-`/` dir-only patterns, leading-`/` or
85
- // internal-`/` anchoring, `**` / `*` / `?` wildcards, per-file last-match
86
- // wins, and deeper .gitignore files override shallower ones.
87
- // ---------------------------------------------------------------------------
88
-
89
- interface IgnoreRule {
90
- negated: boolean;
91
- dirOnly: boolean;
92
- /** Tests a path relative to the directory holding the .gitignore. */
93
- regex: RegExp;
94
- }
95
-
96
- function globSegmentToRegexSource(segment: string): string {
97
- return segment
98
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
99
- .replace(/\*/g, "[^/]*")
100
- .replace(/\?/g, "[^/]");
101
- }
102
-
103
- function patternToRegex(pattern: string, anchored: boolean): RegExp | null {
104
- const segments = pattern.split("/");
105
- let source = "^";
106
- if (!anchored) source += "(?:[^/]+/)*";
107
- for (let index = 0; index < segments.length; index++) {
108
- const segment = segments[index]!;
109
- const last = index === segments.length - 1;
110
- if (segment === "**") {
111
- // Non-trailing `**` means "any depth below here (including zero)";
112
- // trailing `**` means "everything below the prefix". The separator
113
- // handling falls out naturally: the non-last branch already ends with
114
- // `/` inside the group, and the next loop iteration handles the real
115
- // segment after `**`.
116
- source += last ? ".*" : "(?:[^/]+/)*";
117
- continue;
118
- }
119
- source += globSegmentToRegexSource(segment);
120
- if (!last) source += "/";
121
- }
122
- try {
123
- // Case-insensitive everywhere: UKP targets Windows hosts where git
124
- // defaults to core.ignorecase=true, and a nav/build divergence on the
125
- // same .gitignore would be a visibility split, not a nicety.
126
- return new RegExp(`${source}$`, "i");
127
- } catch {
128
- return null;
129
- }
130
- }
131
-
132
- export function parseGitignoreRules(content: string): IgnoreRule[] {
133
- const rules: IgnoreRule[] = [];
134
- for (const rawLine of content.split(/\r?\n/)) {
135
- const line = rawLine.replace(/\s+$/, "");
136
- if (line.length === 0 || line.startsWith("#")) continue;
137
- let pattern = line;
138
- let negated = false;
139
- if (pattern.startsWith("!")) {
140
- negated = true;
141
- pattern = pattern.slice(1);
142
- }
143
- if (pattern.length === 0) continue;
144
- let dirOnly = false;
145
- if (pattern.endsWith("/")) {
146
- dirOnly = true;
147
- pattern = pattern.slice(0, -1);
148
- }
149
- let anchored = false;
150
- if (pattern.startsWith("/")) {
151
- anchored = true;
152
- pattern = pattern.slice(1);
153
- } else if (pattern.includes("/")) {
154
- anchored = true;
155
- }
156
- if (pattern.length === 0 || pattern.split("/").some((segment) => segment.length === 0)) continue;
157
- const regex = patternToRegex(pattern, anchored);
158
- if (regex) rules.push({ negated, dirOnly, regex });
159
- }
160
- return rules;
161
- }
162
-
163
- function readGitignoreRules(dir: string): IgnoreRule[] {
164
- try {
165
- return parseGitignoreRules(readFileSync(join(dir, ".gitignore"), "utf8"));
166
- } catch {
167
- return [];
168
- }
169
- }
170
-
171
- /** One .gitignore file's rules, anchored at the directory holding it. */
172
- interface IgnoreLayer {
173
- base: string;
174
- rules: IgnoreRule[];
175
- }
176
-
177
- function appendLayer(layers: readonly IgnoreLayer[], dir: string): IgnoreLayer[] {
178
- const rules = readGitignoreRules(dir);
179
- return rules.length === 0 ? [...layers] : [...layers, { base: dir, rules }];
180
- }
181
-
182
- /** Builds the inherited chain from the Service folder down to a route root. */
183
- function buildInheritedLayers(from: string, to: string): IgnoreLayer[] {
184
- const rel = relative(from, to).replace(/\\/g, "/");
185
- let layers: IgnoreLayer[] = appendLayer([], from);
186
- if (rel.length === 0 || rel === ".") return layers;
187
- let acc = from;
188
- for (const segment of rel.split("/")) {
189
- acc = join(acc, segment);
190
- layers = appendLayer(layers, acc);
191
- }
192
- return layers;
193
- }
194
-
195
- /** Last matching rule wins; deeper .gitignore files' rules come later. */
196
- function isIgnored(absolutePath: string, isDirectory: boolean, layers: readonly IgnoreLayer[]): boolean {
197
- let ignored = false;
198
- for (const layer of layers) {
199
- const rel = relative(layer.base, absolutePath).replace(/\\/g, "/");
200
- if (rel.length === 0 || rel === ".." || rel.startsWith("../")) continue;
201
- for (const rule of layer.rules) {
202
- if (ruleMatches(rule, rel, isDirectory)) ignored = !rule.negated;
203
- }
204
- }
205
- return ignored;
206
- }
207
-
208
- function ruleMatches(rule: IgnoreRule, path: string, isDirectory: boolean): boolean {
209
- if (!rule.dirOnly) return rule.regex.test(path);
210
- // A dir-only pattern ignores the directory and everything under it: test
211
- // the path itself (when it is a directory) plus every ancestor.
212
- if (isDirectory && rule.regex.test(path)) return true;
213
- let ancestor = "";
214
- for (const segment of path.split("/")) {
215
- ancestor = ancestor.length === 0 ? segment : `${ancestor}/${segment}`;
216
- if (rule.regex.test(ancestor)) return true;
217
- }
218
- return false;
67
+ return raw as string[];
219
68
  }
220
69
 
221
70
  export interface NavEntry {
@@ -224,25 +73,21 @@ export interface NavEntry {
224
73
  path: string;
225
74
  kind: "file" | "folder";
226
75
  description: string | null;
227
- /** Depth-boundary folders only: `true` with `omittedMarkdownCount`. */
76
+ /** Depth-boundary folders only: `true` with `omitted_markdown_count`. */
228
77
  truncated?: boolean;
229
78
  /** Recursive `.md` total under a truncated folder (exclusions applied). */
230
- omittedMarkdownCount?: number;
231
- /** Budget-hit entries only (ADR 0018): the entry IS listed (path came from
232
- * the cheap scan), but its description read was skipped beyond the
233
- * configurable description-read budget. Loud by contract — the entry
234
- * marker plus the `description-budget-reached` diagnostic always appear
235
- * together, never a silent drop. */
236
- descriptionOmitted?: "budget";
79
+ omitted_markdown_count?: number;
80
+ /** Truncated folder entries only: the representative README child's
81
+ * endpoint-relative POSIX path, mapped verbatim from the engine's
82
+ * `NavNode.markdown` — present iff that README is in the tree (W2 Q4
83
+ * restoration, 2026-10-31; zero derivation at the projection layer). */
84
+ markdown?: string;
237
85
  }
238
86
 
239
87
  export interface NavDiagnostic {
240
- code:
241
- | "unreadable-directory"
242
- | "unreadable-file"
243
- | "max-entries-reached"
244
- | "description-budget-reached";
88
+ code: string;
245
89
  message: string;
90
+ path?: string;
246
91
  }
247
92
 
248
93
  export interface NavResult {
@@ -251,8 +96,12 @@ export interface NavResult {
251
96
  root: string;
252
97
  depth: number;
253
98
  entries: NavEntry[];
254
- routeCount: number;
99
+ route_count: number;
255
100
  diagnostics: NavDiagnostic[];
101
+ /** Maintainer-audience findings (owner R6 Q5 = E1, 2026-10-22): engine
102
+ * audience triage — these never enter the ukp.nav.v1 envelope; the human
103
+ * face renders them to stderr, the JSON face stays silent. */
104
+ maintainerDiagnostics: NavDiagnostic[];
256
105
  }
257
106
 
258
107
  export interface NavRequest {
@@ -286,7 +135,8 @@ export interface NavFailure {
286
135
 
287
136
  /** ADR 0021 capability outcome: structured success (`NavResult` is the
288
137
  * `ukp.nav.v1` envelope body) or a classified failure. Never rendered text,
289
- * never exit codes. */
138
+ * never exit codes. Async since the engine delegation (the scan package
139
+ * reads bounded heads through an in-flight window, 2026-10-22). */
290
140
  export type NavOutcome =
291
141
  | { ok: true; result: NavResult }
292
142
  | { ok: false; failure: NavFailure };
@@ -308,246 +158,158 @@ export class NavProviderError extends Error {
308
158
  export const NAV_MAX_DEPTH = 10;
309
159
  export const NAV_DEFAULT_DEPTH = 0;
310
160
 
311
- function isExcludedDir(name: string, visibility: NavVisibility): boolean {
312
- // Case-insensitive to match the gitignore "i" stance (Windows hosts,
313
- // core.ignorecase=true): `.gitignore` writing `Node_Modules/` must not
314
- // resurrect a node_modules tree.
315
- const lower = name.toLowerCase();
316
- return visibility.excludeDirs.includes(lower);
317
- }
318
-
319
- function isExcludedFile(name: string, visibility: NavVisibility): boolean {
320
- return visibility.excludeFiles.includes(name.toLowerCase());
161
+ function toNavDiagnostic(diagnostic: ScanDiagnostic): NavDiagnostic {
162
+ return {
163
+ code: diagnostic.code,
164
+ message: diagnostic.message,
165
+ ...(diagnostic.path === undefined ? {} : { path: diagnostic.path }),
166
+ };
321
167
  }
322
168
 
323
- function isMarkdown(name: string): boolean {
324
- return name.toLowerCase().endsWith(".md");
169
+ function childByName(node: NavNode, name: string): NavNode | undefined {
170
+ return (node.children ?? []).find((child) => child.name === name);
325
171
  }
326
172
 
327
- function isReadme(name: string): boolean {
328
- return name.toLowerCase() === "readme.md";
173
+ /** Descends the scanned tree to the route root. The walk covers the whole
174
+ * Service folder (gitignore inheritance from the Service root down is part
175
+ * of the engine's semantics), so the route root is a subtree selection. */
176
+ function navigateTo(tree: NavNode, segments: readonly string[]): NavNode {
177
+ let current = tree;
178
+ for (const segment of segments) {
179
+ const next = childByName(current, segment);
180
+ if (next === undefined || next.kind !== "dir") {
181
+ throw new NavProviderError(`route root segment '${segment}' vanished from the scan tree`);
182
+ }
183
+ current = next;
184
+ }
185
+ return current;
329
186
  }
330
187
 
331
- function toRoutePath(serviceFolder: string, target: string): string {
332
- const rel = relative(serviceFolder, target).replace(/\\/g, "/");
333
- return rel.length === 0 ? "." : rel;
188
+ /** Recursive `.md` total under a node — the depth-boundary truncation count.
189
+ * The structure walk already applied every exclusion, so this is exact. */
190
+ function countMarkdown(node: NavNode): number {
191
+ let total = 0;
192
+ for (const child of node.children ?? []) {
193
+ if (child.kind === "file") total += 1;
194
+ else total += countMarkdown(child);
195
+ }
196
+ return total;
334
197
  }
335
198
 
336
- // Frontmatter `description:` extraction (single shallow read; a missing or
337
- // malformed block simply yields null — nav never fails because a document
338
- // lacks a description).
339
- function extractDescription(content: string): string | null {
340
- const lines = content.split("\n");
341
- if (lines[0]?.trim() !== "---") return null;
342
- const closing = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
343
- if (closing < 0) return null;
344
- for (const line of lines.slice(1, closing)) {
345
- if (/^\s/.test(line)) continue; // nested keys never own the description
346
- const separator = line.indexOf(":");
347
- if (separator < 0) continue;
348
- if (line.slice(0, separator).trim() !== "description") continue;
349
- const value = line.slice(separator + 1).trim();
350
- if (value.length === 0) return null;
351
- if (
352
- (value.startsWith('"') && value.endsWith('"') && value.length >= 2)
353
- || (value.startsWith("'") && value.endsWith("'") && value.length >= 2)
354
- ) {
355
- return value.slice(1, -1);
356
- }
357
- return value;
199
+ /** The folder's own README path (engine file-node names are stems), for the
200
+ * description hydration set: engine folder descriptions fold from the
201
+ * sidecar ?? own-README chain once the README hydrates. */
202
+ function readmePathOf(node: NavNode): string | undefined {
203
+ for (const child of node.children ?? []) {
204
+ if (child.kind === "file" && child.name.toLowerCase() === "readme") return child.path;
358
205
  }
359
- return null;
206
+ return undefined;
360
207
  }
361
208
 
362
- interface TruncatedFolder {
363
- folder: string;
364
- markdownCount: number;
365
- description: string | null;
209
+ interface FlatEntry {
210
+ path: string;
211
+ kind: "file" | "folder";
212
+ node: NavNode;
213
+ /** Folder entries only. */
214
+ omitted_markdown_count?: number;
366
215
  }
367
216
 
368
- /** Recursive `.md` count under a depth-boundary folder, plus a shallow read
369
- * of its first-level README for the description. The count is the total that
370
- * would appear on expansion — independent of the observing route root.
371
- * Ignore layers are inherited so a truncated folder's count already excludes
372
- * gitignored content. */
373
- function describeTruncatedFolder(
374
- serviceFolder: string,
375
- folder: string,
376
- diagnostics: NavDiagnostic[],
377
- layers: readonly IgnoreLayer[],
378
- visibility: NavVisibility,
379
- ): TruncatedFolder {
380
- let markdownCount = 0;
381
- let description: string | null = null;
382
- const walk = (dir: string, isRoot: boolean, currentLayers: readonly IgnoreLayer[]): void => {
383
- let children;
384
- try {
385
- children = readdirSync(dir, { withFileTypes: true });
386
- } catch {
387
- if (isRoot) {
388
- diagnostics.push({
389
- code: "unreadable-directory",
390
- message: `unable to read directory ${toRoutePath(serviceFolder, dir)}`,
391
- });
392
- }
393
- return;
394
- }
395
- const withLayers = appendLayer(currentLayers, dir);
396
- for (const child of children) {
397
- if (child.name.startsWith(".")) continue;
398
- const childPath = join(dir, child.name);
399
- if (child.isDirectory()) {
400
- if (isExcludedDir(child.name, visibility)) continue;
401
- if (isIgnored(childPath, true, withLayers)) continue;
402
- walk(childPath, false, withLayers);
403
- } else if (child.isFile() && isMarkdown(child.name)) {
404
- if (isExcludedFile(child.name, visibility)) continue;
405
- if (isIgnored(childPath, false, withLayers)) continue;
406
- markdownCount++;
407
- if (isRoot && isReadme(child.name) && description === null) {
408
- try {
409
- description = extractDescription(readFileSync(childPath, "utf8"));
410
- } catch {
411
- description = null;
412
- }
413
- }
217
+ function flattenToDepth(node: NavNode, remaining: number, out: FlatEntry[]): void {
218
+ for (const child of node.children ?? []) {
219
+ if (child.kind === "file") {
220
+ out.push({ path: child.path, kind: "file", node: child });
221
+ } else if (remaining > 0) {
222
+ flattenToDepth(child, remaining - 1, out);
223
+ } else {
224
+ const count = countMarkdown(child);
225
+ if (count > 0) {
226
+ out.push({ path: child.path, kind: "folder", node: child, omitted_markdown_count: count });
414
227
  }
415
228
  }
416
- };
417
- walk(folder, true, layers);
418
- return { folder, markdownCount, description };
419
- }
420
-
421
- interface ScanState {
422
- files: string[];
423
- truncated: TruncatedFolder[];
424
- diagnostics: NavDiagnostic[];
229
+ }
425
230
  }
426
231
 
427
- function scanDirectory(
232
+ async function buildNavResult(
428
233
  serviceFolder: string,
429
- dir: string,
234
+ routeRootFolder: string,
235
+ root: string,
430
236
  depth: number,
431
- maxDepth: number,
432
- state: ScanState,
433
- layers: readonly IgnoreLayer[],
434
- visibility: NavVisibility,
435
- ): void {
436
- let children;
437
- try {
438
- children = readdirSync(dir, { withFileTypes: true });
439
- } catch {
440
- state.diagnostics.push({
441
- code: "unreadable-directory",
442
- message: `unable to read directory ${toRoutePath(serviceFolder, dir)}`,
237
+ endpoint: string,
238
+ options: ResolvedScanOptions,
239
+ ): Promise<NavResult> {
240
+ // B1 (owner R6 Q2, 2026-10-22): structure walk with zero content reads,
241
+ // then hydrate descriptions only for the window survivors.
242
+ const structure = await scanStructure(serviceFolder, builtinDescriptionProvider, options);
243
+ const routeRelative = relative(serviceFolder, routeRootFolder).replace(/\\/g, "/");
244
+ const segments = routeRelative.length === 0 || routeRelative === "." ? [] : routeRelative.split("/");
245
+ const routeNode = navigateTo(structure.tree, segments);
246
+
247
+ const flat: FlatEntry[] = [];
248
+ flattenToDepth(routeNode, depth, flat);
249
+
250
+ // A1 window: full walk, then the projection window cuts (loud, same shape).
251
+ const diagnostics: NavDiagnostic[] = [];
252
+ let survivors = flat;
253
+ if (flat.length > NAV_MAX_ENTRIES) {
254
+ survivors = flat.slice(0, NAV_MAX_ENTRIES);
255
+ diagnostics.push({
256
+ code: "max-entries-reached",
257
+ message: `route view truncated at ${NAV_MAX_ENTRIES} entries`,
443
258
  });
444
- return;
445
259
  }
446
- const withLayers = appendLayer(layers, dir);
447
- children.sort((left, right) => left.name.localeCompare(right.name));
448
-
449
- for (const child of children) {
450
- if (child.name.startsWith(".")) continue;
451
- const childPath = join(dir, child.name);
452
-
453
- if (child.isDirectory()) {
454
- if (isExcludedDir(child.name, visibility)) continue;
455
- if (isIgnored(childPath, true, withLayers)) continue;
456
- if (depth >= maxDepth) {
457
- const truncated = describeTruncatedFolder(serviceFolder, childPath, state.diagnostics, withLayers, visibility);
458
- if (truncated.markdownCount > 0) {
459
- if (state.files.length + state.truncated.length >= NAV_MAX_ENTRIES) {
460
- state.diagnostics.push({
461
- code: "max-entries-reached",
462
- message: `route view truncated at ${NAV_MAX_ENTRIES} entries`,
463
- });
464
- return;
465
- }
466
- state.truncated.push(truncated);
467
- }
468
- continue;
469
- }
470
- scanDirectory(serviceFolder, childPath, depth + 1, maxDepth, state, withLayers, visibility);
471
- continue;
260
+
261
+ // Hydration set = window file entries + surviving truncated folders' own
262
+ // READMEs (file hydration by window; folder descriptions fold full-tree
263
+ // inside the engine on every describe — coordinator verification ③).
264
+ const hydration = new Set<string>();
265
+ for (const entry of survivors) {
266
+ if (entry.kind === "file") {
267
+ hydration.add(entry.path);
268
+ } else {
269
+ const readme = readmePathOf(entry.node);
270
+ if (readme !== undefined) hydration.add(readme);
472
271
  }
272
+ }
473
273
 
474
- if (child.isFile() && isMarkdown(child.name)) {
475
- if (isExcludedFile(child.name, visibility)) continue;
476
- if (isIgnored(childPath, false, withLayers)) continue;
477
- if (state.files.length + state.truncated.length >= NAV_MAX_ENTRIES) {
478
- state.diagnostics.push({
479
- code: "max-entries-reached",
480
- message: `route view truncated at ${NAV_MAX_ENTRIES} entries`,
481
- });
482
- return;
483
- }
484
- state.files.push(childPath);
274
+ // E1 audience triage: engine consumer findings ride the envelope;
275
+ // maintainer findings ride the stderr-only channel.
276
+ const maintainerDiagnostics: NavDiagnostic[] = [];
277
+ for (const diagnostic of structure.diagnostics) {
278
+ if (diagnostic.audience === "consumer") diagnostics.push(toNavDiagnostic(diagnostic));
279
+ else maintainerDiagnostics.push(toNavDiagnostic(diagnostic));
280
+ }
281
+ const describeDiagnostics = await structure.describe(hydration);
282
+ // Engine items carry placeholder text where the description chain found
283
+ // nothing ("Undocumented — …"); the ukp.nav.v1 contract keeps null. The
284
+ // per-file maintainer diagnostics name exactly those paths.
285
+ const nullDescriptionPaths = new Set<string>();
286
+ for (const diagnostic of describeDiagnostics) {
287
+ if (diagnostic.audience !== "maintainer") continue;
288
+ maintainerDiagnostics.push(toNavDiagnostic(diagnostic));
289
+ if (
290
+ diagnostic.path !== undefined
291
+ && (diagnostic.code === "no-description" || diagnostic.code === "unreadable-file")
292
+ ) {
293
+ nullDescriptionPaths.add(diagnostic.path);
485
294
  }
486
295
  }
487
- }
488
296
 
489
- function buildNavResult(
490
- serviceFolder: string,
491
- routeRootFolder: string,
492
- root: string,
493
- depth: number,
494
- endpoint: string,
495
- visibility: NavVisibility,
496
- descriptionBudget: number,
497
- ): NavResult {
498
- const state: ScanState = { files: [], truncated: [], diagnostics: [] };
499
- // Ignore layers are inherited from the Service folder down to the route
500
- // root, then continue accumulating during the scan.
501
- const layers = buildInheritedLayers(serviceFolder, routeRootFolder);
502
- scanDirectory(serviceFolder, routeRootFolder, 0, depth, state, layers, visibility);
503
-
504
- // Description extraction is the expensive cost class (ADR 0018): a full
505
- // file read per entry. Beyond the configurable budget the entry stays
506
- // listed (the path came from the cheap scan) but its description is
507
- // omitted, flagged per-entry, and announced once by diagnostic — loud,
508
- // never silent.
509
- let descriptionsRead = 0;
510
- let descriptionsOmitted = 0;
511
- const entries: NavEntry[] = state.files.map((file) => {
512
- if (descriptionsRead >= descriptionBudget) {
513
- descriptionsOmitted += 1;
297
+ const entries: NavEntry[] = survivors.map((entry) => {
298
+ if (entry.kind === "folder") {
514
299
  return {
515
- path: toRoutePath(serviceFolder, file),
516
- kind: "file" as const,
517
- description: null,
518
- descriptionOmitted: "budget" as const,
300
+ path: entry.path,
301
+ kind: "folder",
302
+ description: entry.node.description ?? null,
303
+ truncated: true,
304
+ omitted_markdown_count: entry.omitted_markdown_count,
305
+ ...(entry.node.markdown === undefined ? {} : { markdown: entry.node.markdown }),
519
306
  };
520
307
  }
521
- descriptionsRead += 1;
522
- let description: string | null = null;
523
- try {
524
- description = extractDescription(readFileSync(file, "utf8"));
525
- } catch {
526
- state.diagnostics.push({
527
- code: "unreadable-file",
528
- message: `unable to read markdown file ${toRoutePath(serviceFolder, file)}`,
529
- });
530
- }
531
- return { path: toRoutePath(serviceFolder, file), kind: "file" as const, description };
308
+ const description = nullDescriptionPaths.has(entry.path)
309
+ ? null
310
+ : entry.node.item?.description ?? null;
311
+ return { path: entry.path, kind: "file", description };
532
312
  });
533
- if (descriptionsOmitted > 0) {
534
- state.diagnostics.push({
535
- code: "description-budget-reached",
536
- message:
537
- `descriptions omitted for ${descriptionsOmitted} entries beyond the description-read budget `
538
- + `${descriptionBudget} (nav config 'max_description_files'; entries stay listed, counts stay exact)`,
539
- });
540
- }
541
-
542
- for (const truncated of state.truncated) {
543
- entries.push({
544
- path: toRoutePath(serviceFolder, truncated.folder),
545
- kind: "folder",
546
- description: truncated.description,
547
- truncated: true,
548
- omittedMarkdownCount: truncated.markdownCount,
549
- });
550
- }
551
313
 
552
314
  entries.sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: "base" }));
553
315
  return {
@@ -555,8 +317,9 @@ function buildNavResult(
555
317
  root,
556
318
  depth,
557
319
  entries,
558
- routeCount: entries.length,
559
- diagnostics: state.diagnostics,
320
+ route_count: entries.length,
321
+ diagnostics,
322
+ maintainerDiagnostics,
560
323
  };
561
324
  }
562
325
 
@@ -580,7 +343,11 @@ export function validateNavRoutePath(routePath: string): string[] {
580
343
 
581
344
  /** Presentation projection (ADR 0021 two-stage form): the structured,
582
345
  * serializable view. First-class citizen — adapters (CLI today, MCP /
583
- * programmatic later) consume this view, never the raw scan result. */
346
+ * programmatic later) consume this view, never the raw scan result.
347
+ * Maintainer diagnostics stay out (E1): the envelope is the agent face.
348
+ * Wire names are UKP-owned snake_case (owner ruling 2026-11-03): the
349
+ * projection layer owns the wire schema, so engine-internal camelCase
350
+ * never leaks past it. */
584
351
  export interface NavEnvelope {
585
352
  schema: "ukp.nav.v1";
586
353
  command: "nav";
@@ -588,7 +355,7 @@ export interface NavEnvelope {
588
355
  endpoint: string;
589
356
  root: string;
590
357
  depth: number;
591
- routeCount: number;
358
+ route_count: number;
592
359
  entries: NavEntry[];
593
360
  diagnostics: NavDiagnostic[];
594
361
  }
@@ -601,7 +368,7 @@ export function projectNavEnvelope(result: NavResult): NavEnvelope {
601
368
  endpoint: result.endpoint,
602
369
  root: result.root,
603
370
  depth: result.depth,
604
- routeCount: result.routeCount,
371
+ route_count: result.route_count,
605
372
  entries: result.entries,
606
373
  diagnostics: result.diagnostics,
607
374
  };
@@ -616,10 +383,8 @@ export function renderNavHuman(envelope: NavEnvelope): string {
616
383
  if (entry.kind === "folder") {
617
384
  // Self-explanatory unexpanded form: an unexpanded folder plus the exact
618
385
  // recursive .md count inside (never "truncated" — nothing was cut).
619
- const line = `${entry.path}/ (+${entry.omittedMarkdownCount} .md)`;
386
+ const line = `${entry.path}/ (+${entry.omitted_markdown_count} .md)`;
620
387
  lines.push(entry.description ? `${line} | ${entry.description}` : line);
621
- } else if (entry.descriptionOmitted === "budget") {
622
- lines.push(`${entry.path} | (description omitted: budget reached)`);
623
388
  } else {
624
389
  lines.push(entry.description ? `${entry.path} | ${entry.description}` : entry.path);
625
390
  }
@@ -632,11 +397,12 @@ export function renderNavJson(envelope: NavEnvelope): string {
632
397
  }
633
398
 
634
399
  /** ADR 0021 core entry: runs the nav capability and returns a structured
635
- * outcome. Scope/manifest/provider failures that cannot be classified as a
636
- * nav failure still throw their typed errors (`ScopeError`,
637
- * `ManifestError`, `NavUsageError`, `NavProviderError`) for the surface
638
- * adapter to map. */
639
- export function runNav(request: NavRequest, context: NavContext): NavOutcome {
400
+ * outcome. Async since the scan-engine delegation (2026-10-22) — the local
401
+ * and remote branches now share one async contract.
402
+ * Scope/manifest/provider failures that cannot be classified as a nav
403
+ * failure still throw their typed errors (`ScopeError`, `ManifestError`,
404
+ * `NavUsageError`, `NavProviderError`) for the surface adapter to map. */
405
+ export async function runNav(request: NavRequest, context: NavContext): Promise<NavOutcome> {
640
406
  let depth = NAV_DEFAULT_DEPTH;
641
407
  if (request.depth !== undefined) {
642
408
  if (!Number.isSafeInteger(request.depth) || request.depth < 0 || request.depth > NAV_MAX_DEPTH) {
@@ -723,14 +489,13 @@ export function runNav(request: NavRequest, context: NavContext): NavOutcome {
723
489
  root = rel.length === 0 ? "." : rel.replace(/\\/g, "/");
724
490
  }
725
491
 
726
- const result = buildNavResult(
492
+ const result = await buildNavResult(
727
493
  service.folder,
728
494
  routeRootFolder,
729
495
  root,
730
496
  depth,
731
497
  binding.name,
732
- resolveNavVisibility(resolved.capability),
733
- resolveNavDescriptionBudget(resolved.capability),
498
+ resolveNavScanOptions(resolved.capability),
734
499
  );
735
500
  return { ok: true, result };
736
501
  }