@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.
- package/README.md +93 -21
- package/SECURITY.md +69 -0
- package/bin/ukp.js +31 -7
- package/docs/remote-deployment.md +175 -19
- package/package.json +11 -6
- package/skills/ukp/SKILL.md +32 -0
- package/src/capabilities/door-health.ts +480 -0
- package/src/capabilities/nav.ts +209 -444
- package/src/capabilities/qmd.ts +19 -0
- package/src/capabilities/read.ts +18 -2
- package/src/capabilities/remote-client.ts +4 -2
- package/src/capabilities/search.ts +23 -7
- package/src/capabilities/session.ts +455 -0
- package/src/capabilities/tls-identity.ts +55 -3
- package/src/cli.ts +38 -64
- package/src/commands/diagnose.ts +80 -2
- package/src/commands/guide-content/client.txt +19 -6
- package/src/commands/guide-content/remote.txt +22 -1
- package/src/commands/guide-content/rg.txt +2 -2
- package/src/commands/init.ts +243 -1
- package/src/commands/inventory.ts +277 -17
- package/src/commands/nav.ts +17 -12
- package/src/commands/read.ts +1 -1
- package/src/commands/rg.ts +2 -2
- package/src/commands/search.ts +19 -3
- package/src/commands/serve-task.ts +334 -44
- package/src/commands/serve.ts +410 -44
- package/src/commands/session.ts +541 -0
- package/src/commands/use.ts +178 -0
- package/src/config/agent-backed.ts +136 -0
- package/src/config/manifest.ts +39 -3
- package/src/manifest.yml +15 -0
- package/src/path-safety.ts +9 -4
- package/src/plumbing/token-file.ts +368 -0
- package/src/registry.ts +78 -1
- package/src/server.ts +291 -75
- package/src/spawn-relay.ts +21 -3
package/src/capabilities/nav.ts
CHANGED
|
@@ -1,42 +1,61 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
10
|
-
//
|
|
11
|
-
// provider-owned visibility: the exclusion rules
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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 `
|
|
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
|
-
|
|
231
|
-
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
324
|
-
return
|
|
169
|
+
function childByName(node: NavNode, name: string): NavNode | undefined {
|
|
170
|
+
return (node.children ?? []).find((child) => child.name === name);
|
|
325
171
|
}
|
|
326
172
|
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
function
|
|
340
|
-
const
|
|
341
|
-
|
|
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
|
|
206
|
+
return undefined;
|
|
360
207
|
}
|
|
361
208
|
|
|
362
|
-
interface
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
232
|
+
async function buildNavResult(
|
|
428
233
|
serviceFolder: string,
|
|
429
|
-
|
|
234
|
+
routeRootFolder: string,
|
|
235
|
+
root: string,
|
|
430
236
|
depth: number,
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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:
|
|
516
|
-
kind: "
|
|
517
|
-
description: null,
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
559
|
-
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
*
|
|
639
|
-
|
|
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
|
-
|
|
733
|
-
resolveNavDescriptionBudget(resolved.capability),
|
|
498
|
+
resolveNavScanOptions(resolved.capability),
|
|
734
499
|
);
|
|
735
500
|
return { ok: true, result };
|
|
736
501
|
}
|