@any-nav/scan 0.0.0-stage → 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/dist/scan.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { NavNode, ScanConfig, ScanDiagnostic } from './types.ts';
2
+ /**
3
+ * Agent-instruction files harnesses load on their own; they carry no
4
+ * navigation value (the agent already has them) so they never appear in
5
+ * navigation (routes DEFAULT_EXCLUDE_FILES, ported).
6
+ */
7
+ export declare const DEFAULT_EXCLUDE_FILES: readonly string[];
8
+ /** routes DEFAULT_EXCLUDE_DIRS, ported verbatim. Always on (the safety
9
+ * floor); user excludeDirs entries append to it, never replace it — a
10
+ * generate-era 'any-nav' entry was retired with `list --write` (the
11
+ * materialization target is now any file the caller names). */
12
+ export declare const DEFAULT_EXCLUDE_DIRS: readonly string[];
13
+ export interface ResolvedScanOptions {
14
+ excludeDirs: readonly string[];
15
+ excludeFiles: readonly string[];
16
+ excludeDotEntries: boolean;
17
+ respectGitignore: boolean;
18
+ maxFiles: number;
19
+ }
20
+ export declare function resolveScanOptions(config?: ScanConfig): ResolvedScanOptions;
21
+ export interface ScannedFile {
22
+ path: string;
23
+ content: string;
24
+ }
25
+ /** Port: built-in chain is the ported routes extraction; LLM providers arrive in v1.x. */
26
+ export interface DescriptionProvider {
27
+ extract(file: ScannedFile): Promise<string | null>;
28
+ }
29
+ export interface ScanOutput {
30
+ tree: NavNode;
31
+ itemCount: number;
32
+ diagnostics: ScanDiagnostic[];
33
+ /**
34
+ * Dir paths (walk order) whose descent the maxFiles budget cut short —
35
+ * the cut chain (fact-safe cut, ADR-0010 U12): the folder where the
36
+ * budget flipped plus every surviving ancestor. Their recursive counts
37
+ * are floors, never totals; views render ≥N and the walk events carry a
38
+ * per-row diagnostic. Identical for the structure walk.
39
+ */
40
+ countIncompletePaths: string[];
41
+ }
42
+ export declare const builtinDescriptionProvider: DescriptionProvider;
43
+ export declare function isExcludedFileName(name: string, excludeFiles: readonly string[]): boolean;
44
+ /**
45
+ * Directory description sidecars (2026-10-07): the Docusaurus ecosystem
46
+ * names — a folder may state its navigation description in one of these
47
+ * instead of its README (release-face READMEs stay untouched). The first
48
+ * existing name in this order is authoritative; the files are never Markdown
49
+ * so they never enter the tree, and they never consume the maxFiles budget.
50
+ */
51
+ export declare const DIR_SIDECAR_NAMES: readonly string[];
52
+ export declare function isDirSidecarName(name: string): boolean;
53
+ export declare function scanSource(root: string, provider?: DescriptionProvider, options?: ResolvedScanOptions): Promise<ScanOutput>;
54
+ export interface StructureScanOutput {
55
+ tree: NavNode;
56
+ itemCount: number;
57
+ /** Walk diagnostics (directory findings + file-limit); per-file
58
+ * diagnostics (no-description / unreadable-file) ride describe(). */
59
+ diagnostics: ScanDiagnostic[];
60
+ /**
61
+ * Dir paths whose descent the maxFiles budget cut short — counts are
62
+ * floors (≥ rows, per-row diagnostics already in `diagnostics`);
63
+ * identical to scanSource's list (same cut point, U11/U12).
64
+ */
65
+ countIncompletePaths: string[];
66
+ /**
67
+ * Hydrate descriptions for the given source-relative Markdown paths, in
68
+ * place: file nodes gain their NavItem, and folders fold descriptions
69
+ * (sidecar ?? own README chain, identical to a full scan) once their
70
+ * README hydrates. Returns the per-file diagnostics in walk order.
71
+ * Idempotent — already-hydrated paths are skipped; unknown paths are
72
+ * ignored. Reads fetch bounded heads under the same in-flight window.
73
+ */
74
+ describe(paths: Iterable<string>): Promise<ScanDiagnostic[]>;
75
+ }
76
+ /**
77
+ * Structure-only walk: every exclusion, gitignore, budget, and pruning rule
78
+ * of scanSource with zero content reads — file nodes carry no item until
79
+ * describe() hydrates the ones a view actually renders. The tree shape and
80
+ * itemCount are exactly scanSource's (the budget counts entries, not reads).
81
+ */
82
+ export declare function scanStructure(root: string, provider?: DescriptionProvider, options?: ResolvedScanOptions): Promise<StructureScanOutput>;
package/dist/scan.js ADDED
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Scan: walk a Markdown folder into a NavTree under the routes traversal
3
+ * semantics (ADR-0010 absorption — port, don't rewrite): exclusion policies,
4
+ * gitignore inheritance, a loud maxFiles budget, empty-directory pruning, and
5
+ * folder descriptions — from a `_category_` sidecar when the folder carries
6
+ * one (the exception channel for release-face READMEs), else from the
7
+ * folder's own README.md. Description extraction is the ported routes chain
8
+ * (description.ts); null falls to core's placeholder + diagnostic, which
9
+ * routes does not have and core keeps (stricter).
10
+ *
11
+ * Read-only by contract: scanning must never modify source files
12
+ * (map is not territory).
13
+ *
14
+ * Two entry points share one walk (2026-10-20): scanSource reads and
15
+ * extracts every Markdown file (snapshot semantics — the search/candidates
16
+ * anchor); scanStructure walks structure-only and hydrates descriptions on
17
+ * demand via describe(), so a depth-0 view pays reads for the rows it
18
+ * renders, not the whole tree. Reads overlap behind a bounded in-flight
19
+ * window — budget cut, tree shape, and diagnostic order stay byte-identical
20
+ * to the serial walk this replaced — and fetch a bounded head that provably
21
+ * covers the extraction chain's input (frontmatter + the body-head window),
22
+ * extended adaptively, never more bytes than the chain can consume.
23
+ */
24
+ import { open, readdir, readFile, stat } from 'node:fs/promises';
25
+ import { basename, extname, join, relative, sep } from 'node:path';
26
+ import { BODY_HEAD_LINE_LIMIT, extractDescription, extractSidecarDescription } from './description.js';
27
+ import { isGitignored, readGitignoreRules } from './gitignore.js';
28
+ const MARKDOWN_EXT = '.md';
29
+ /**
30
+ * Agent-instruction files harnesses load on their own; they carry no
31
+ * navigation value (the agent already has them) so they never appear in
32
+ * navigation (routes DEFAULT_EXCLUDE_FILES, ported).
33
+ */
34
+ export const DEFAULT_EXCLUDE_FILES = ['AGENTS.md', 'CLAUDE.md'];
35
+ /** routes DEFAULT_EXCLUDE_DIRS, ported verbatim. Always on (the safety
36
+ * floor); user excludeDirs entries append to it, never replace it — a
37
+ * generate-era 'any-nav' entry was retired with `list --write` (the
38
+ * materialization target is now any file the caller names). */
39
+ export const DEFAULT_EXCLUDE_DIRS = [
40
+ '.git',
41
+ '.hg',
42
+ '.svn',
43
+ 'node_modules',
44
+ '.pnpm',
45
+ 'dist',
46
+ 'build',
47
+ 'lib',
48
+ 'out',
49
+ ];
50
+ export function resolveScanOptions(config) {
51
+ // Additive ruling (ADR-0010 update 2026-10-07): the built-in tables are the
52
+ // always-on floor, user entries append — "the file states only exceptions"
53
+ // must not mean restating the defaults to add one. Duplicates are harmless
54
+ // (membership checks only), so a plain concat stays honest.
55
+ return {
56
+ excludeDirs: [...DEFAULT_EXCLUDE_DIRS, ...(config?.excludeDirs ?? [])],
57
+ excludeFiles: [...DEFAULT_EXCLUDE_FILES, ...(config?.excludeFiles ?? [])],
58
+ excludeDotEntries: config?.excludeDotEntries ?? true,
59
+ respectGitignore: config?.respectGitignore ?? true,
60
+ maxFiles: config?.maxFiles ?? 2000,
61
+ };
62
+ }
63
+ export const builtinDescriptionProvider = {
64
+ extract: (file) => Promise.resolve(extractDescription(file.content)),
65
+ };
66
+ export function isExcludedFileName(name, excludeFiles) {
67
+ if (excludeFiles.length === 0)
68
+ return false;
69
+ const lower = name.toLowerCase();
70
+ return excludeFiles.some((excluded) => excluded.toLowerCase() === lower);
71
+ }
72
+ /**
73
+ * Directory description sidecars (2026-10-07): the Docusaurus ecosystem
74
+ * names — a folder may state its navigation description in one of these
75
+ * instead of its README (release-face READMEs stay untouched). The first
76
+ * existing name in this order is authoritative; the files are never Markdown
77
+ * so they never enter the tree, and they never consume the maxFiles budget.
78
+ */
79
+ export const DIR_SIDECAR_NAMES = ['_category_.yml', '_category_.yaml', '_category_.json'];
80
+ export function isDirSidecarName(name) {
81
+ const lower = name.toLowerCase();
82
+ return DIR_SIDECAR_NAMES.some((sidecar) => sidecar === lower);
83
+ }
84
+ /**
85
+ * Whether a directory's already-listed entries carry a .gitignore — the
86
+ * walk opens the file only when readdir saw it, skipping the failing
87
+ * probe open per gitignore-less directory that dominated large-tree scan
88
+ * latency (case-insensitive: a case-insensitive filesystem would have
89
+ * resolved the probe open the same way).
90
+ */
91
+ function carriesGitignore(entries) {
92
+ return entries.some((entry) => entry.isFile() && entry.name.toLowerCase() === '.gitignore');
93
+ }
94
+ /**
95
+ * Read the directory's sidecar description from its already-listed entries.
96
+ * Returns undefined when no sidecar exists, when the authoritative one is
97
+ * gitignored (traversal honors the same rules as content), or when it carries
98
+ * no description key — the README chain then fills in. Unreadable or invalid
99
+ * sidecars fire a loud diagnostic and fall through the same way.
100
+ */
101
+ async function sidecarDescription(absDir, relDir, entries, ctx, gitignoreRules) {
102
+ for (const sidecarName of DIR_SIDECAR_NAMES) {
103
+ const entry = entries.find((candidate) => candidate.name.toLowerCase() === sidecarName && candidate.isFile());
104
+ if (entry === undefined)
105
+ continue;
106
+ const abs = join(absDir, entry.name);
107
+ if (ctx.opts.respectGitignore && isGitignored(abs, false, gitignoreRules))
108
+ return undefined;
109
+ const rel = relDir === '' ? entry.name : `${relDir}/${entry.name}`;
110
+ let content;
111
+ try {
112
+ content = await readFile(abs, 'utf8');
113
+ }
114
+ catch {
115
+ ctx.events.push({
116
+ kind: 'diag',
117
+ diagnostic: {
118
+ code: 'sidecar-unreadable',
119
+ severity: 'warning',
120
+ message: `Unable to read directory description sidecar ${rel}.`,
121
+ path: rel,
122
+ audience: 'maintainer',
123
+ },
124
+ });
125
+ return undefined;
126
+ }
127
+ const read = extractSidecarDescription(entry.name, content);
128
+ if ('error' in read) {
129
+ ctx.events.push({
130
+ kind: 'diag',
131
+ diagnostic: {
132
+ code: 'sidecar-unreadable',
133
+ severity: 'warning',
134
+ message: `Directory description sidecar ${rel}: ${read.error}.`,
135
+ path: rel,
136
+ audience: 'maintainer',
137
+ },
138
+ });
139
+ return undefined;
140
+ }
141
+ return read.description !== null && read.description.trim() !== '' ? read.description : undefined;
142
+ }
143
+ return undefined;
144
+ }
145
+ /** Read fan-out ceiling: the 2026-10-20 probe saturated at 8 lanes (~4.3x,
146
+ * 39→9ms over 177 files) with nothing at 16 — the window keeps stragglers
147
+ * from stalling the pipeline and bounds transient memory to the window's
148
+ * in-flight file heads. */
149
+ const MAX_INFLIGHT_READS = 16;
150
+ /** Bounded in-flight read window: kicks never reject (read errors resolve
151
+ * null — the unreadable-file path), and the gate waits for the next settle
152
+ * before kicking past the ceiling. gate() then start() keeps the started
153
+ * promise unwrapped for fire-and-continue callers. */
154
+ class BoundedReads {
155
+ pending = [];
156
+ /** Wait for a window slot (no-op while under the ceiling). */
157
+ async gate() {
158
+ if (this.pending.length >= MAX_INFLIGHT_READS) {
159
+ await Promise.race(this.pending);
160
+ }
161
+ }
162
+ /** Start one read and return its still-running promise. */
163
+ start(abs) {
164
+ const content = readDescriptionInput(abs);
165
+ this.pending.push(content);
166
+ void content.then(() => {
167
+ const index = this.pending.indexOf(content);
168
+ if (index !== -1)
169
+ this.pending.splice(index, 1);
170
+ });
171
+ return content;
172
+ }
173
+ }
174
+ /** First head rung and the extension factor: extraction reads frontmatter
175
+ * plus a 15-line body-head window (fences/HTML comments stripped inside
176
+ * it), so a head closing the frontmatter and showing 16+ open body lines
177
+ * with nothing dangling at the cut is provably equivalent to the full file
178
+ * for the chain — the ladder only climbs for long frontmatter or
179
+ * fence/comment-heavy heads. */
180
+ const DESC_HEAD_BYTES = 16 * 1024;
181
+ const DESC_HEAD_LADDER = 4;
182
+ /**
183
+ * Read the file head the description chain consumes — never more bytes than
184
+ * the chain can read, provably equivalent input or the ladder climbs (the
185
+ * whole file is the terminal rung). Any I/O error resolves null (the
186
+ * unreadable-file path); the full readFile this replaced had the same
187
+ * contract with far more bytes.
188
+ */
189
+ async function readDescriptionInput(abs) {
190
+ let handle;
191
+ try {
192
+ handle = await open(abs);
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ try {
198
+ let capacity = DESC_HEAD_BYTES;
199
+ for (;;) {
200
+ const bytes = await readUpTo(handle, capacity);
201
+ const text = bytes.toString('utf8');
202
+ if (bytes.length < capacity || headCoversExtraction(text))
203
+ return text;
204
+ capacity *= DESC_HEAD_LADDER;
205
+ }
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ finally {
211
+ await handle.close().catch(() => { });
212
+ }
213
+ }
214
+ async function readUpTo(handle, capacity) {
215
+ const buffer = Buffer.alloc(capacity);
216
+ let filled = 0;
217
+ while (filled < capacity) {
218
+ const { bytesRead } = await handle.read(buffer, filled, capacity - filled, filled);
219
+ if (bytesRead === 0)
220
+ break;
221
+ filled += bytesRead;
222
+ }
223
+ return filled === capacity ? buffer : buffer.subarray(0, filled);
224
+ }
225
+ /**
226
+ * Whether a head provably covers every byte extractDescription reads:
227
+ * frontmatter closed when opened; past it, at least BODY_HEAD_LINE_LIMIT + 1
228
+ * open lines (the chain slices 15 — the +1 guarantees the 15th ended with a
229
+ * newline and is complete) with no code fence or HTML comment left dangling
230
+ * at the cut (mirrors stripCodeFences / stripHtmlComments state shapes; a
231
+ * dangling marker means later content could still change the strip).
232
+ */
233
+ function headCoversExtraction(text) {
234
+ const lines = text.split(/\r?\n/u);
235
+ let start = 0;
236
+ if (lines[0]?.trim() === '---') {
237
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
238
+ if (end === -1)
239
+ return false;
240
+ start = end + 1;
241
+ }
242
+ let inFence = false;
243
+ let inComment = false;
244
+ let openLines = 0;
245
+ for (let index = start; index < lines.length; index += 1) {
246
+ const line = lines[index];
247
+ if (/^\s*(`{3,}|~{3,})/u.test(line)) {
248
+ inFence = !inFence;
249
+ continue;
250
+ }
251
+ if (inFence)
252
+ continue;
253
+ let rest = line;
254
+ while (rest.length > 0) {
255
+ if (!inComment) {
256
+ const opens = rest.indexOf('<!--');
257
+ if (opens === -1)
258
+ break;
259
+ rest = rest.slice(opens + 4);
260
+ inComment = true;
261
+ }
262
+ else {
263
+ const closes = rest.indexOf('-->');
264
+ if (closes === -1) {
265
+ rest = '';
266
+ break;
267
+ }
268
+ rest = rest.slice(closes + 3);
269
+ inComment = false;
270
+ }
271
+ }
272
+ openLines += 1;
273
+ }
274
+ return !inFence && !inComment && openLines >= BODY_HEAD_LINE_LIMIT + 1;
275
+ }
276
+ /** Settle one started read: the description lands on the item and any
277
+ * per-file diagnostic (unreadable-file / no-description placeholder) goes
278
+ * to the sink at the caller's position. */
279
+ async function settleDescription(read, provider, sink) {
280
+ const content = await read.content;
281
+ if (content === null) {
282
+ // routes port: an unreadable Markdown file still becomes a bare-path
283
+ // route (diagnostic fires, the entry survives) — it must not silently
284
+ // vanish from navigation, and it still consumes the maxFiles budget.
285
+ sink.push({
286
+ code: 'unreadable-file',
287
+ severity: 'warning',
288
+ message: `Unable to read Markdown file ${read.rel}.`,
289
+ path: read.rel,
290
+ audience: 'maintainer',
291
+ });
292
+ read.item.description = `Undocumented — unreadable file ${read.rel}`;
293
+ return;
294
+ }
295
+ const extracted = await provider.extract({ path: read.rel, content });
296
+ read.item.description =
297
+ extracted !== null && extracted.trim() !== '' ? extracted : placeholder(read.rel, sink);
298
+ }
299
+ /**
300
+ * Post-walk folder descriptions, bottom-up over the assembled tree — the
301
+ * same chain the serial walk computed inline: sidecar first, else the
302
+ * folder's own first README child (children are name-sorted) carrying a
303
+ * real description. Runs after the settle, so file descriptions are final.
304
+ */
305
+ function assignFolderDescriptions(node, ctx) {
306
+ let readmeDescription;
307
+ for (const child of node.children ?? []) {
308
+ if (child.kind === 'dir')
309
+ assignFolderDescriptions(child, ctx);
310
+ if (readmeDescription === undefined &&
311
+ child.kind === 'file' &&
312
+ child.name.toLowerCase() === 'readme' &&
313
+ child.item !== undefined &&
314
+ !child.item.description.startsWith('Undocumented')) {
315
+ readmeDescription = child.item.description;
316
+ }
317
+ }
318
+ const description = ctx.sidecars.get(node) ?? readmeDescription;
319
+ if (description !== undefined)
320
+ node.description = description;
321
+ }
322
+ async function performWalk(root, options, eager) {
323
+ const opts = options ?? resolveScanOptions();
324
+ const budget = { files: 0, limitHit: opts.maxFiles <= 0 };
325
+ const ctx = {
326
+ opts,
327
+ events: [],
328
+ files: new Map(),
329
+ sidecars: new Map(),
330
+ reads: new BoundedReads(),
331
+ eager,
332
+ countIncompletePaths: [],
333
+ };
334
+ const rootEntries = await sortedEntries(root, ctx);
335
+ // The readdir already says whether the root carries a .gitignore — probe
336
+ // opens for the (common) absent case are pure walk-thread latency, so
337
+ // only real files are opened (the same gate applies per directory below).
338
+ const rootRules = opts.respectGitignore && carriesGitignore(rootEntries)
339
+ ? await readGitignoreRules(root)
340
+ : [];
341
+ // Root description: the same chain as any directory — sidecar first, else
342
+ // the root README's own extraction (uniform semantics, no root special case).
343
+ const rootSidecar = await sidecarDescription(root, '', rootEntries, ctx, rootRules);
344
+ const children = [];
345
+ for (const entry of rootEntries) {
346
+ if (isDirSidecarName(entry.name))
347
+ continue; // consumed by rootSidecar above
348
+ const node = await scanEntry(root, root, entry, ctx, rootRules, budget);
349
+ if (node === null)
350
+ continue;
351
+ children.push(node);
352
+ }
353
+ const tree = rootNode(root);
354
+ if (children.length > 0)
355
+ tree.children = children;
356
+ ctx.sidecars.set(tree, rootSidecar);
357
+ return { ctx, tree, itemCount: budget.files, events: ctx.events, limitHit: budget.limitHit };
358
+ }
359
+ function fileLimitDiagnostic(maxFiles) {
360
+ return {
361
+ code: 'file-limit-reached',
362
+ severity: 'warning',
363
+ message: `Markdown file limit (${maxFiles}) reached while scanning; entries past the limit were not examined — folders whose descent was cut carry ≥ counts (a floor, not the total) and later folders are absent. Raise scan.maxFiles for the full tree.`,
364
+ path: '.',
365
+ audience: 'consumer',
366
+ };
367
+ }
368
+ /**
369
+ * Per-row disclosure for one floor-count folder (ADR-0010 U12): the budget
370
+ * cut this folder's descent mid-way, so the count a view renders for it is
371
+ * a floor. Consumer audience — it is view fidelity, same class as
372
+ * file-limit-reached.
373
+ */
374
+ function countTruncatedDiagnostic(rel) {
375
+ return {
376
+ code: 'count-truncated-at-limit',
377
+ severity: 'warning',
378
+ message: `The maxFiles budget cut this folder's descent short; its shown count is a floor (at least that many), and entries past the cut point are absent.`,
379
+ path: rel,
380
+ audience: 'consumer',
381
+ };
382
+ }
383
+ export async function scanSource(root, provider = builtinDescriptionProvider, options) {
384
+ let rootStat;
385
+ try {
386
+ rootStat = await stat(root);
387
+ }
388
+ catch {
389
+ return { tree: rootNode(root), itemCount: 0, diagnostics: [rootDiagnostic('scan-root-not-found', `Scan root does not exist or is unreadable.`)], countIncompletePaths: [] };
390
+ }
391
+ if (rootStat.isFile()) {
392
+ return { tree: rootNode(root), itemCount: 0, diagnostics: [rootDiagnostic('scan-root-is-file', `Scan root is a file. Navigation scans start from folders.`)], countIncompletePaths: [] };
393
+ }
394
+ if (!rootStat.isDirectory()) {
395
+ return { tree: rootNode(root), itemCount: 0, diagnostics: [rootDiagnostic('scan-root-unsupported', `Scan root is neither a directory nor a Markdown file.`)], countIncompletePaths: [] };
396
+ }
397
+ const walk = await performWalk(root, options, true);
398
+ const diagnostics = [];
399
+ // Drain: settle the started reads in walk order (they overlapped during
400
+ // the walk) — descriptions and per-file diagnostics land exactly where
401
+ // the serial walk would have put them.
402
+ for (const event of walk.events) {
403
+ if (event.kind === 'diag') {
404
+ diagnostics.push(event.diagnostic);
405
+ continue;
406
+ }
407
+ await settleDescription(event.read, provider, diagnostics);
408
+ }
409
+ if (walk.limitHit)
410
+ diagnostics.push(fileLimitDiagnostic(walk.ctx.opts.maxFiles));
411
+ assignFolderDescriptions(walk.tree, walk.ctx);
412
+ return { tree: walk.tree, itemCount: walk.itemCount, diagnostics, countIncompletePaths: walk.ctx.countIncompletePaths };
413
+ }
414
+ /**
415
+ * Structure-only walk: every exclusion, gitignore, budget, and pruning rule
416
+ * of scanSource with zero content reads — file nodes carry no item until
417
+ * describe() hydrates the ones a view actually renders. The tree shape and
418
+ * itemCount are exactly scanSource's (the budget counts entries, not reads).
419
+ */
420
+ export async function scanStructure(root, provider = builtinDescriptionProvider, options) {
421
+ let rootStat;
422
+ try {
423
+ rootStat = await stat(root);
424
+ }
425
+ catch {
426
+ return structureError(root, 'scan-root-not-found', `Scan root does not exist or is unreadable.`);
427
+ }
428
+ if (rootStat.isFile()) {
429
+ return structureError(root, 'scan-root-is-file', `Scan root is a file. Navigation scans start from folders.`);
430
+ }
431
+ if (!rootStat.isDirectory()) {
432
+ return structureError(root, 'scan-root-unsupported', `Scan root is neither a directory nor a Markdown file.`);
433
+ }
434
+ const walk = await performWalk(root, options, false);
435
+ const diagnostics = [];
436
+ for (const event of walk.events) {
437
+ if (event.kind === 'diag')
438
+ diagnostics.push(event.diagnostic);
439
+ }
440
+ if (walk.limitHit)
441
+ diagnostics.push(fileLimitDiagnostic(walk.ctx.opts.maxFiles));
442
+ return {
443
+ tree: walk.tree,
444
+ itemCount: walk.itemCount,
445
+ diagnostics,
446
+ countIncompletePaths: walk.ctx.countIncompletePaths,
447
+ describe: async (paths) => {
448
+ const targeted = paths instanceof Set ? paths : new Set(paths);
449
+ const records = [...walk.ctx.files.values()].filter((record) => targeted.has(record.rel) && record.node.item === undefined);
450
+ if (records.length === 0) {
451
+ assignFolderDescriptions(walk.tree, walk.ctx);
452
+ return [];
453
+ }
454
+ const reads = [];
455
+ for (const record of records) {
456
+ await walk.ctx.reads.gate();
457
+ const item = { name: record.node.name, description: '', locator: { kind: 'file', path: record.rel } };
458
+ record.node.item = item;
459
+ reads.push({ rel: record.rel, content: walk.ctx.reads.start(record.abs), item });
460
+ }
461
+ const sink = [];
462
+ for (const read of reads)
463
+ await settleDescription(read, provider, sink);
464
+ assignFolderDescriptions(walk.tree, walk.ctx);
465
+ return sink;
466
+ },
467
+ };
468
+ }
469
+ function structureError(root, code, message) {
470
+ return {
471
+ tree: rootNode(root),
472
+ itemCount: 0,
473
+ diagnostics: [rootDiagnostic(code, message)],
474
+ countIncompletePaths: [],
475
+ describe: async () => [],
476
+ };
477
+ }
478
+ /**
479
+ * Scan one directory entry into a tree node, or null when it carries no
480
+ * Markdown descendant (empty-directory pruning, ADR-0010; a dir with only
481
+ * pruned subdirs prunes too — a directory with a README is never empty).
482
+ * Eager walks kick reads here and settle at the drain; structure walks
483
+ * record the file for describe(). Either way descriptions finalize before
484
+ * the caller's output contract completes.
485
+ */
486
+ async function scanEntry(root, absDir, entry, ctx, gitignoreRules, budget) {
487
+ const { opts } = ctx;
488
+ if (budget.files >= opts.maxFiles) {
489
+ budget.limitHit = true;
490
+ return null;
491
+ }
492
+ if (opts.excludeDotEntries && entry.name.startsWith('.'))
493
+ return null;
494
+ const abs = join(absDir, entry.name);
495
+ const rel = relPath(root, abs);
496
+ if (opts.respectGitignore && isGitignored(abs, entry.isDirectory(), gitignoreRules))
497
+ return null;
498
+ if (entry.isDirectory()) {
499
+ if (opts.excludeDirs.includes(entry.name))
500
+ return null;
501
+ const dirEntries = await sortedEntries(abs, ctx);
502
+ // The readdir above already says whether this dir carries a .gitignore
503
+ // — opening the file only then skips the failing probe per
504
+ // gitignore-less directory (the walk-thread latency that dominated
505
+ // large-tree scans; same gate as the root).
506
+ const nestedRules = opts.respectGitignore && carriesGitignore(dirEntries)
507
+ ? [...gitignoreRules, ...(await readGitignoreRules(abs))]
508
+ : gitignoreRules;
509
+ const sidecar = await sidecarDescription(abs, rel, dirEntries, ctx, nestedRules);
510
+ const children = [];
511
+ for (const child of dirEntries) {
512
+ if (isDirSidecarName(child.name))
513
+ continue; // consumed by sidecar above
514
+ const node = await scanEntry(root, abs, child, ctx, nestedRules, budget);
515
+ if (node === null)
516
+ continue;
517
+ children.push(node);
518
+ }
519
+ if (children.length === 0)
520
+ return null;
521
+ const node = { name: entry.name, path: rel, kind: 'dir', children };
522
+ ctx.sidecars.set(node, sidecar);
523
+ // Fact-safe cut (ADR-0010 U12): the budget flipped inside this folder's
524
+ // span, so it survives with partial children — as does every surviving
525
+ // ancestor, which marks itself the same way at its own loop's end (the
526
+ // chain of folders whose counts are floors). A partial count must never
527
+ // present as the total: the path joins the floor-count set and the
528
+ // per-row diagnostic fires (consumer audience). Folders never entered
529
+ // after the flip vanish instead — the global file-limit-reached covers
530
+ // those, unchanged.
531
+ if (budget.limitHit) {
532
+ ctx.countIncompletePaths.push(rel);
533
+ ctx.events.push({ kind: 'diag', diagnostic: countTruncatedDiagnostic(rel) });
534
+ }
535
+ return node;
536
+ }
537
+ if (entry.isFile() && extname(entry.name).toLowerCase() === MARKDOWN_EXT) {
538
+ if (isExcludedFileName(entry.name, opts.excludeFiles))
539
+ return null;
540
+ const node = { name: stem(entry.name), path: rel, kind: 'file' };
541
+ // Eager start, deferred settle: the read overlaps the rest of the walk
542
+ // while the budget counts synchronously at kick-off — the cut point
543
+ // (first maxFiles in sorted DFS order) is the serial walk's.
544
+ if (ctx.eager) {
545
+ await ctx.reads.gate();
546
+ const item = { name: node.name, description: '', locator: { kind: 'file', path: rel } };
547
+ node.item = item;
548
+ ctx.events.push({ kind: 'file', read: { rel, content: ctx.reads.start(abs), item } });
549
+ }
550
+ else {
551
+ ctx.files.set(rel, { rel, abs, node });
552
+ }
553
+ budget.files += 1;
554
+ return node;
555
+ }
556
+ return null;
557
+ }
558
+ async function sortedEntries(dir, ctx) {
559
+ let entries;
560
+ try {
561
+ entries = await readdir(dir, { withFileTypes: true });
562
+ }
563
+ catch {
564
+ ctx.events.push({
565
+ kind: 'diag',
566
+ diagnostic: {
567
+ code: 'unreadable-directory',
568
+ severity: 'warning',
569
+ message: `Unable to read directory.`,
570
+ path: dir,
571
+ audience: 'maintainer',
572
+ },
573
+ });
574
+ return [];
575
+ }
576
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
577
+ }
578
+ function rootDiagnostic(code, message) {
579
+ return { code, severity: 'warning', message, path: '.', audience: 'consumer' };
580
+ }
581
+ function placeholder(relPath, diagnostics) {
582
+ diagnostics.push({
583
+ code: 'no-description',
584
+ severity: 'warning',
585
+ // The code states the condition; the message carries only the datum
586
+ // (coggit's WARN | code | source=... convention — no restating sentence).
587
+ message: relPath,
588
+ path: relPath,
589
+ audience: 'maintainer',
590
+ });
591
+ return `Undocumented — add a description to ${relPath}`;
592
+ }
593
+ function rootNode(root) {
594
+ const name = basename(root);
595
+ return { name: name === '' || name === '.' || name === '/' ? 'root' : name, path: '', kind: 'dir' };
596
+ }
597
+ function stem(fileName) {
598
+ return fileName.slice(0, fileName.length - extname(fileName).length);
599
+ }
600
+ function relPath(root, abs) {
601
+ return relative(root, abs).split(sep).join('/');
602
+ }
603
+ //# sourceMappingURL=scan.js.map