@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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- 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
|
+
}
|