@catheadowl/ukp 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,734 @@
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { loadManifest, type ManifestCapability } from "../config/manifest.ts";
4
+ import { resolveFileNativeCapability, unsupportedFileNativeProviderMessage } from "../config/file-native.ts";
5
+ import { localPathOf, readRegistry } from "../registry.ts";
6
+ import { resolveScope } from "../scope.ts";
7
+ import { isInsideRealRoot, splitEndpointRelativeSegments } from "../path-safety.ts";
8
+
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[];
40
+ }
41
+
42
+ function parseStringList(config: Record<string, unknown>, key: string): string[] | undefined {
43
+ const raw = config[key];
44
+ if (raw === undefined) return undefined;
45
+ if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string" || item.length === 0)) {
46
+ throw new NavProviderError(`nav config '${key}' must be an array of non-empty strings`);
47
+ }
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 `[truncated: N]` 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;
219
+ }
220
+
221
+ export interface NavEntry {
222
+ /** Endpoint-relative route path, forward slashes. Files keep their `.md`
223
+ * suffix so the path is directly consumable by `ukp read`. */
224
+ path: string;
225
+ kind: "file" | "folder";
226
+ description: string | null;
227
+ /** Depth-boundary folders only: `true` with `omittedMarkdownCount`. */
228
+ truncated?: boolean;
229
+ /** 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";
237
+ }
238
+
239
+ export interface NavDiagnostic {
240
+ code:
241
+ | "unreadable-directory"
242
+ | "unreadable-file"
243
+ | "max-entries-reached"
244
+ | "description-budget-reached";
245
+ message: string;
246
+ }
247
+
248
+ export interface NavResult {
249
+ endpoint: string;
250
+ /** Route root the depth window was computed from ("." = endpoint root). */
251
+ root: string;
252
+ depth: number;
253
+ entries: NavEntry[];
254
+ routeCount: number;
255
+ diagnostics: NavDiagnostic[];
256
+ }
257
+
258
+ export interface NavRequest {
259
+ endpoint: string;
260
+ path?: string;
261
+ depth?: number;
262
+ json: boolean;
263
+ }
264
+
265
+ export interface NavContext {
266
+ currentDirectory: string;
267
+ registryPath: string;
268
+ }
269
+
270
+ /** Structured failure (ADR 0021): factual data only — the `ukp nav:` prefix,
271
+ * exit code, and recovery wording are adapter renderings, not capability
272
+ * data. `errorClass` is the stable classification the exit-code mapping
273
+ * consumes. */
274
+ export type NavErrorClass =
275
+ | "no-endpoint"
276
+ | "endpoint-name-mismatch"
277
+ | "provider-unsupported"
278
+ | "route-root-not-found"
279
+ | "route-root-not-directory";
280
+
281
+ export interface NavFailure {
282
+ errorClass: NavErrorClass;
283
+ /** Factual message without any surface prefix or hint phrasing. */
284
+ message: string;
285
+ }
286
+
287
+ /** ADR 0021 capability outcome: structured success (`NavResult` is the
288
+ * `ukp.nav.v1` envelope body) or a classified failure. Never rendered text,
289
+ * never exit codes. */
290
+ export type NavOutcome =
291
+ | { ok: true; result: NavResult }
292
+ | { ok: false; failure: NavFailure };
293
+
294
+ export class NavUsageError extends Error {
295
+ constructor(message: string) {
296
+ super(message);
297
+ this.name = "NavUsageError";
298
+ }
299
+ }
300
+
301
+ export class NavProviderError extends Error {
302
+ constructor(message: string) {
303
+ super(message);
304
+ this.name = "NavProviderError";
305
+ }
306
+ }
307
+
308
+ export const NAV_MAX_DEPTH = 10;
309
+ export const NAV_DEFAULT_DEPTH = 0;
310
+
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());
321
+ }
322
+
323
+ function isMarkdown(name: string): boolean {
324
+ return name.toLowerCase().endsWith(".md");
325
+ }
326
+
327
+ function isReadme(name: string): boolean {
328
+ return name.toLowerCase() === "readme.md";
329
+ }
330
+
331
+ function toRoutePath(serviceFolder: string, target: string): string {
332
+ const rel = relative(serviceFolder, target).replace(/\\/g, "/");
333
+ return rel.length === 0 ? "." : rel;
334
+ }
335
+
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;
358
+ }
359
+ return null;
360
+ }
361
+
362
+ interface TruncatedFolder {
363
+ folder: string;
364
+ markdownCount: number;
365
+ description: string | null;
366
+ }
367
+
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
+ }
414
+ }
415
+ }
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[];
425
+ }
426
+
427
+ function scanDirectory(
428
+ serviceFolder: string,
429
+ dir: string,
430
+ 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)}`,
443
+ });
444
+ return;
445
+ }
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;
472
+ }
473
+
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);
485
+ }
486
+ }
487
+ }
488
+
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;
514
+ return {
515
+ path: toRoutePath(serviceFolder, file),
516
+ kind: "file" as const,
517
+ description: null,
518
+ descriptionOmitted: "budget" as const,
519
+ };
520
+ }
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 };
532
+ });
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
+
552
+ entries.sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: "base" }));
553
+ return {
554
+ endpoint,
555
+ root,
556
+ depth,
557
+ entries,
558
+ routeCount: entries.length,
559
+ diagnostics: state.diagnostics,
560
+ };
561
+ }
562
+
563
+ /** Lexical route-path validation (usage-error tier, shared by the command
564
+ * parser and the capability): endpoint-relative, no empty / `.` / `..`
565
+ * segments. Existence and containment are checked later at execution. */
566
+ export function validateNavRoutePath(routePath: string): string[] {
567
+ // Path-safety primitive (ADR 0023): verdicts shared, error wording local.
568
+ const result = splitEndpointRelativeSegments(routePath);
569
+ if (!result.ok) {
570
+ if (result.reason === "absolute") {
571
+ throw new NavUsageError("path must be endpoint-relative, not absolute");
572
+ }
573
+ if (result.reason === "empty-segment") {
574
+ throw new NavUsageError("path must not contain empty path segments");
575
+ }
576
+ throw new NavUsageError("path must not contain '.' or '..' path segments");
577
+ }
578
+ return result.segments;
579
+ }
580
+
581
+ /** Presentation projection (ADR 0021 two-stage form): the structured,
582
+ * serializable view. First-class citizen — adapters (CLI today, MCP /
583
+ * programmatic later) consume this view, never the raw scan result. */
584
+ export interface NavEnvelope {
585
+ schema: "ukp.nav.v1";
586
+ command: "nav";
587
+ capability: "nav";
588
+ endpoint: string;
589
+ root: string;
590
+ depth: number;
591
+ routeCount: number;
592
+ entries: NavEntry[];
593
+ diagnostics: NavDiagnostic[];
594
+ }
595
+
596
+ export function projectNavEnvelope(result: NavResult): NavEnvelope {
597
+ return {
598
+ schema: "ukp.nav.v1",
599
+ command: "nav",
600
+ capability: "nav",
601
+ endpoint: result.endpoint,
602
+ root: result.root,
603
+ depth: result.depth,
604
+ routeCount: result.routeCount,
605
+ entries: result.entries,
606
+ diagnostics: result.diagnostics,
607
+ };
608
+ }
609
+
610
+ export function renderNavHuman(envelope: NavEnvelope): string {
611
+ const lines = [`endpoint: ${envelope.endpoint} (root: ${envelope.root}, depth: ${envelope.depth})`];
612
+ if (envelope.entries.length === 0) {
613
+ lines.push("no markdown routes under this root");
614
+ }
615
+ for (const entry of envelope.entries) {
616
+ if (entry.kind === "folder") {
617
+ const line = `[truncated: ${entry.omittedMarkdownCount}] ${entry.path}`;
618
+ lines.push(entry.description ? `${line} | ${entry.description}` : line);
619
+ } else if (entry.descriptionOmitted === "budget") {
620
+ lines.push(`${entry.path} | (description omitted: budget reached)`);
621
+ } else {
622
+ lines.push(entry.description ? `${entry.path} | ${entry.description}` : entry.path);
623
+ }
624
+ }
625
+ return `${lines.join("\n")}\n`;
626
+ }
627
+
628
+ export function renderNavJson(envelope: NavEnvelope): string {
629
+ return `${JSON.stringify(envelope, null, 2)}\n`;
630
+ }
631
+
632
+ /** ADR 0021 core entry: runs the nav capability and returns a structured
633
+ * outcome. Scope/manifest/provider failures that cannot be classified as a
634
+ * nav failure still throw their typed errors (`ScopeError`,
635
+ * `ManifestError`, `NavUsageError`, `NavProviderError`) for the surface
636
+ * adapter to map. */
637
+ export function runNav(request: NavRequest, context: NavContext): NavOutcome {
638
+ let depth = NAV_DEFAULT_DEPTH;
639
+ if (request.depth !== undefined) {
640
+ if (!Number.isSafeInteger(request.depth) || request.depth < 0 || request.depth > NAV_MAX_DEPTH) {
641
+ throw new NavUsageError(`--depth must be an integer between 0 and ${NAV_MAX_DEPTH}`);
642
+ }
643
+ depth = request.depth;
644
+ }
645
+
646
+ const registry = readRegistry(context.registryPath);
647
+ const scope = resolveScope({
648
+ currentDirectory: context.currentDirectory,
649
+ registry,
650
+ explicitEndpoints: [request.endpoint],
651
+ global: false,
652
+ });
653
+ const [binding] = scope.bindings;
654
+ if (!binding) {
655
+ return { ok: false, failure: { errorClass: "no-endpoint", message: "no endpoint selected" } };
656
+ }
657
+ // Remote bindings are routed to the remote transport by the surface
658
+ // adapter before this point (ukp_remote W6); reaching here with one means
659
+ // a programmatic caller — localPathOf gives it the classified refusal.
660
+
661
+ const service = loadManifest(localPathOf(binding));
662
+ if (service.effectiveName !== binding.name) {
663
+ return {
664
+ ok: false,
665
+ failure: {
666
+ errorClass: "endpoint-name-mismatch",
667
+ message: `endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
668
+ },
669
+ };
670
+ }
671
+
672
+ // Nav is a file-native derived default (ADR 0016): every registered local
673
+ // Service has nav/file unless it declares otherwise. Resolution goes
674
+ // through the shared file-native table — no command-local fallback.
675
+ const resolved = resolveFileNativeCapability(service.manifest, "nav");
676
+ if (!resolved || resolved.capability.provider !== "file") {
677
+ return {
678
+ ok: false,
679
+ failure: {
680
+ errorClass: "provider-unsupported",
681
+ message: unsupportedFileNativeProviderMessage("nav", resolved?.capability.provider),
682
+ },
683
+ };
684
+ }
685
+
686
+ // Route root: the optional path must resolve to a directory inside the
687
+ // Service folder. Exact addressing only — no fuzzy candidates: a miss
688
+ // fails precisely instead of guessing.
689
+ let routeRootFolder = service.folder;
690
+ let root = ".";
691
+ if (request.path !== undefined) {
692
+ const segments = validateNavRoutePath(request.path);
693
+ const targetPath = resolve(join(service.folder, ...segments));
694
+ const serviceReal = realpathSync(service.folder);
695
+ let targetReal: string;
696
+ try {
697
+ targetReal = realpathSync(targetPath);
698
+ } catch {
699
+ return {
700
+ ok: false,
701
+ failure: {
702
+ errorClass: "route-root-not-found",
703
+ message: `path '${request.path}' was not found in endpoint '${binding.name}'`,
704
+ },
705
+ };
706
+ }
707
+ const rel = relative(serviceReal, targetReal);
708
+ if (!isInsideRealRoot(serviceReal, targetReal, { allowRoot: true })) {
709
+ throw new NavUsageError("path must stay inside the selected Service folder");
710
+ }
711
+ if (!statSync(targetReal).isDirectory()) {
712
+ return {
713
+ ok: false,
714
+ failure: {
715
+ errorClass: "route-root-not-directory",
716
+ message: `path '${request.path}' is not a directory in endpoint '${binding.name}'`,
717
+ },
718
+ };
719
+ }
720
+ routeRootFolder = targetReal;
721
+ root = rel.length === 0 ? "." : rel.replace(/\\/g, "/");
722
+ }
723
+
724
+ const result = buildNavResult(
725
+ service.folder,
726
+ routeRootFolder,
727
+ root,
728
+ depth,
729
+ binding.name,
730
+ resolveNavVisibility(resolved.capability),
731
+ resolveNavDescriptionBudget(resolved.capability),
732
+ );
733
+ return { ok: true, result };
734
+ }