opencode-orchestrator-uni 0.3.1 → 0.4.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.
@@ -0,0 +1,2482 @@
1
+ /**
2
+ * context-lint — deterministic, read-only checks for instruction files.
3
+ *
4
+ * The tool is the mechanical half of the `context-lint` agent: it measures
5
+ * budget, finds vague phrases, duplicate lines, broken relative links,
6
+ * placeholders, dates in the first lines and missing structural sections. The
7
+ * agent adds the semantic half (conflicts, testability, SPEC intent).
8
+ *
9
+ * Every check is pure with respect to the workspace: files are only read, and
10
+ * the result is a plain object. The caller (`lib/tools.ts`) turns it into a
11
+ * JSON tool body; failures are typed codes, not thrown errors.
12
+ *
13
+ * Containment and cost are part of the contract:
14
+ * - links and explicit targets never leave `root` (`E_OUT_OF_ROOT`); the
15
+ * lexical check is a fast path, the authoritative one is `realpath`, so a
16
+ * symlink/junction pointing outside the root is skipped or refused;
17
+ * - a UNC/backslash/URI target is never handed to `existsSync` (a network path
18
+ * would block the event loop for the SMB timeout);
19
+ * - reads are capped by `maxFileBytes`, directory expansion by `maxFiles`, and
20
+ * findings by `maxFindingsPerFile`/`maxTotalFindings` during the scan. A cap
21
+ * never hides MAJOR silently: above the per-file cap the excess is replaced by
22
+ * one MAJOR `truncated` finding that stays in `closing_list`; `truncated`
23
+ * reports every capped list;
24
+ * - line-oriented parsers scan every line, however long: the link, placeholder
25
+ * and section-heading regexes are linear (no unbounded `\s*` chain next to a
26
+ * lazy `.*?`), so a minified line cannot make matching quadratic and a long
27
+ * line cannot hide a broken link or placeholder behind a skip; the section
28
+ * checks yield to the event loop every `SECTION_ABORT_LINES` lines, so a
29
+ * timer-driven abort wins there too;
30
+ * - code fences, inline code and HTML comments are masked once and every
31
+ * content check reads the masked lines: a sample is not a rule;
32
+ * - nothing is dropped silently: an unclosed fence, a link target outside the
33
+ * root and a directory below the depth limit each leave an INFO finding, and
34
+ * a code span without a closing run is literal text (CommonMark), not a mask
35
+ * to the end of the line.
36
+ */
37
+ import { existsSync } from "node:fs"
38
+ import { open, readdir, realpath, stat } from "node:fs/promises"
39
+ import path from "node:path"
40
+
41
+ export type ContextKind = "agents" | "spec" | "claude" | "other"
42
+ export type ContextSeverity = "MAJOR" | "MINOR" | "INFO"
43
+ export type ContextFindingKind =
44
+ | "budget"
45
+ | "vague"
46
+ | "duplicate"
47
+ | "broken_link"
48
+ | "missing_section"
49
+ | "conflict"
50
+ | "unverified_command"
51
+ | "other"
52
+
53
+ export interface ContextFinding {
54
+ readonly id: string
55
+ readonly severity: ContextSeverity
56
+ readonly kind: ContextFindingKind
57
+ readonly evidence: string
58
+ readonly detail: string
59
+ readonly fix: string
60
+ }
61
+
62
+ export interface ContextFileResult {
63
+ readonly path: string
64
+ readonly kind: ContextKind
65
+ readonly lines: number
66
+ readonly words: number
67
+ readonly chars: number
68
+ readonly tokens_est: number
69
+ readonly findings: ContextFinding[]
70
+ /**
71
+ * True when a cap dropped MINOR/INFO findings of this file or replaced excess
72
+ * MAJOR findings with one MAJOR `truncated` marker (which stays in
73
+ * `closing_list`).
74
+ */
75
+ readonly truncated?: boolean
76
+ }
77
+
78
+ export interface ContextLintResult {
79
+ /** Schema id of this result, so the raw tool output is self-describing. */
80
+ readonly schema: "context-lint/v1"
81
+ readonly ok: true
82
+ readonly verdict: "pass" | "fail"
83
+ readonly closing_list: string[]
84
+ readonly files: ContextFileResult[]
85
+ readonly summary: string
86
+ /**
87
+ * True when a cap dropped findings. Above `maxTotalFindings` MAJOR findings
88
+ * are kept first and the overflow is replaced by one MAJOR `truncated` marker
89
+ * that stays in `closing_list`.
90
+ */
91
+ readonly truncated?: boolean
92
+ }
93
+
94
+ /** Typed refusal codes of `lintContext`. */
95
+ export type ContextLintCode =
96
+ | "E_INPUT"
97
+ | "E_NOT_FOUND"
98
+ | "E_NO_FILES"
99
+ | "E_IO"
100
+ | "E_ABORTED"
101
+ | "E_LIMIT_FILE"
102
+ | "E_LIMIT_FILES"
103
+ | "E_OUT_OF_ROOT"
104
+
105
+ export interface ContextLintFailure {
106
+ /** Schema id of this payload, so a failure is self-describing like a report. */
107
+ readonly schema: "context-lint-failure/v1"
108
+ readonly ok: false
109
+ readonly code: ContextLintCode
110
+ readonly message: string
111
+ /** Path the refusal refers to (limits, out-of-root). */
112
+ readonly path?: string
113
+ /** Observed size in bytes (`E_LIMIT_FILE`). */
114
+ readonly bytes?: number
115
+ /** Observed number of files (`E_LIMIT_FILES`). */
116
+ readonly files?: number
117
+ /** Limit that fired (`E_LIMIT_FILE`, `E_LIMIT_FILES`). */
118
+ readonly max?: number
119
+ }
120
+
121
+ export type ContextLintOutcome = ContextLintResult | ContextLintFailure
122
+
123
+ export interface ContextLintOptions {
124
+ /** Repository root; relative targets are resolved against it. */
125
+ readonly root: string
126
+ /** File or directory to inspect. Defaults to `root`. */
127
+ readonly target?: string
128
+ /** AGENTS/CLAUDE line budget. Default: 200. */
129
+ readonly maxLines?: number
130
+ /** AGENTS/CLAUDE token budget (estimated). Default: 2500. */
131
+ readonly maxTokens?: number
132
+ /** SPEC word budget. Default: 1500 (the authoring target; 2000 is the ceiling). */
133
+ readonly maxWords?: number
134
+ /** Largest file that may be read, in bytes. Default: 2_000_000. */
135
+ readonly maxFileBytes?: number
136
+ /** Most files a directory target may expand to. Default: 500. */
137
+ readonly maxFiles?: number
138
+ /** MINOR/INFO findings kept per file and the hard cap for MAJOR. Default: 100. */
139
+ readonly maxFindingsPerFile?: number
140
+ /**
141
+ * Global cap for every finding. MAJOR findings are kept first; when they
142
+ * overflow, `maxTotal` findings plus one MAJOR `truncated` marker are kept.
143
+ * Default: 1000.
144
+ */
145
+ readonly maxTotalFindings?: number
146
+ /** Optional abort signal, checked around reads and inside directory walks. */
147
+ readonly signal?: AbortSignal
148
+ }
149
+
150
+ const DEFAULT_MAX_FILE_BYTES = 2_000_000
151
+ const DEFAULT_MAX_FILES = 500
152
+ const DEFAULT_MAX_FINDINGS_PER_FILE = 100
153
+ const DEFAULT_MAX_TOTAL_FINDINGS = 1000
154
+ /** Upper bound of a `detail` field that echoes source text. */
155
+ const MAX_DETAIL_LENGTH = 120
156
+ /** Skipped-link INFO findings per scan; the rest is one counter finding. */
157
+ const MAX_SKIPPED_LINK_FINDINGS = 10
158
+ /**
159
+ * Link candidates collected from one file before the list is truncated; above
160
+ * the cap the file is reported incomplete (MAJOR marker) instead of failing on
161
+ * a merely large link list.
162
+ */
163
+ const MAX_LINK_CANDIDATES = 50_000
164
+ /** Candidates between abort checks and a macrotask yield. */
165
+ const LINK_CHECK_YIELD = 100
166
+ /** Source characters between abort checks inside the linear link scanner. */
167
+ const LINK_SCAN_ABORT_INTERVAL = 1_000
168
+ /**
169
+ * Upper bound of every link-check cache (parent real paths, target real paths,
170
+ * probed paths). A pathological file can mention 50k distinct parents/targets;
171
+ * capping keeps the caches small without changing which links are reported
172
+ * (a cache miss only costs one extra `realpath`/`existsSync`).
173
+ */
174
+ const LINK_CACHE_LIMIT = 5_000
175
+
176
+ const SKIP = new Set(["node_modules", ".git", ".ae", "dist", "build", "target", "vendor"])
177
+ const TARGET_NAME = /^(AGENTS|SPEC|CLAUDE)\.md$/i
178
+ /**
179
+ * Linear: `<...>` (three or more dots) or a TODO/TBD/FIXME marker. A tag such
180
+ * as `<DoD>` or a named slot `<name>` is markup, not a placeholder.
181
+ */
182
+ const PLACEHOLDER = /<\.{3,}>|\b(?:TODO|TBD|FIXME)\b/g
183
+ const URI_SCHEME = /^[a-z][a-z0-9+.-]*:/i
184
+ const HEADING = /^#{1,6}\s/
185
+ const HEADING_LEVEL = /^(#{1,6})\s/
186
+ const TABLE_ROW = /^\s*\|/
187
+ /** Optional `1.`/`2)` numbering after the `##` marker: `## 4. Requirements`. */
188
+ const HEADING_PREFIX = "^#{1,6}\\s*(?:\\d+[.)]\\s*)?"
189
+ const OPEN_QUESTIONS_HEADING = new RegExp(`${HEADING_PREFIX}(?:open questions|открытые вопросы)(?=$|[\\s:.,;—–-])`, "i")
190
+
191
+ const COMMANDS_HEADING = new RegExp(`${HEADING_PREFIX}(?:commands?|команды)(?=$|[\\s:.,;—–-])`, "i")
192
+ const TASK_TABLE_HEADER = /\|\s*(?:задача|task)\s*\|/i
193
+ /**
194
+ * Shell tools a command section may name. `make` is matched separately: prose
195
+ * "Make sure to be careful" is not a command, only a `make <target>` form is.
196
+ */
197
+ const SHELL_TOOL = /\b(?:npm|bun|pnpm|yarn|cargo|pytest)\b|\bgo[ \t]+test\b/i
198
+ /** `make` with a target; the stoplist keeps ordinary English prose out. */
199
+ const MAKE_TARGET =
200
+ /\bmake[ \t]+(?!sure\b|sense\b|it\b|them\b|this\b|that\b|these\b|those\b|a\b|an\b|the\b|no\b|not\b|any\b|both\b|your\b|our\b|their\b|him\b|her\b|me\b|us\b)(?:-{1,2}[\w-]+|[\w][\w./:-]*)/i
201
+ const BOUNDARIES = /(never|никогда|ask first|сначала спроси|do not|не редактир|не коммит)/i
202
+ const VERIFICATION = /(verification|verify|definition of done|определение готовности|готовност|готово|проверк)/i
203
+ const DEFINITION_OF_DONE = /(definition of done|\bdod\b|готовност|готово)/i
204
+
205
+ /**
206
+ * A non-goals heading: `## Non-goals`, `## 2. Goal and non-goals`,
207
+ * `## Non-goals: A; B; C`. The optional group is the inline list after the label.
208
+ * Every whitespace run is bounded: an unbounded `\s*` next to the lazy `.*?`
209
+ * made a line of N spaces cost O(N²). `.*?` itself is linear because the tail
210
+ * after the label is bounded.
211
+ */
212
+ const NON_GOALS_HEADING =
213
+ /^#{1,6}[ \t]{0,8}(?:\d+[.)][ \t]{0,8})?.*?(?:non-goals?|non goals?|out of scope|не-цели|не цели|не делаем)(?:[ \t]{0,8}:[ \t]{0,8}(.*)|[ \t]{0,8})$/i
214
+
215
+ /** A wrapped or inline `Non-goals:` label. */
216
+ const NON_GOALS_INLINE =
217
+ /^\s*(?:[-*+]\s+)?(?:\*\*)?(?:non-goals?|non goals?|out of scope|не-цели|не цели|не делаем)(?:\*\*)?\s*[::]\s*(?:\*\*)?\s*(.*)$/i
218
+ const NONE_ITEM = /^(?:none|nothing|n\/a|na|нет|никаких|отсутствуют)\s*[.!]?$/i
219
+
220
+ const VAGUE: readonly { readonly term: string; readonly pattern: RegExp }[] = [
221
+ { term: "clean", pattern: /\bclean\b/i },
222
+ { term: "best practices", pattern: /\bbest practices?\b/i },
223
+ { term: "robust", pattern: /\brobust\b/i },
224
+ { term: "properly", pattern: /\bproperly\b/i },
225
+ { term: "carefully", pattern: /\bcarefully\b/i },
226
+ { term: "optimally", pattern: /\boptimally\b/i },
227
+ { term: "аккуратно", pattern: /аккуратн/i },
228
+ { term: "правильно", pattern: /правильн/i },
229
+ { term: "оптимально", pattern: /оптимальн/i },
230
+ { term: "качественно", pattern: /качественн/i },
231
+ { term: "удобно", pattern: /удобн/i },
232
+ { term: "надёжно", pattern: /надёжн/i },
233
+ ]
234
+
235
+ /** Thrown by the directory walk when the caller aborted; mapped to `E_ABORTED`. */
236
+ class ScanAborted extends Error {
237
+ constructor() {
238
+ super("context lint aborted")
239
+ this.name = "ScanAborted"
240
+ }
241
+ }
242
+
243
+ /** Thrown by the directory walk when `maxFiles` is exceeded; refusal is early. */
244
+ class FilesLimitExceeded extends Error {
245
+ constructor() {
246
+ super("context lint file limit")
247
+ this.name = "FilesLimitExceeded"
248
+ }
249
+ }
250
+
251
+ /** Lines between abort checks and event-loop yields inside a line-oriented check. */
252
+ const SECTION_ABORT_LINES = 2048
253
+
254
+ /**
255
+ * Cooperative abort point inside a synchronous per-line check. Called every
256
+ * `SECTION_ABORT_LINES` lines: the yield lets a timer-driven abort fire, and an
257
+ * already-aborted signal surfaces as `ScanAborted` (mapped to `E_ABORTED`) so a
258
+ * section check can never return a full verdict after the caller aborted.
259
+ */
260
+ async function abortGate(signal: AbortSignal | undefined): Promise<void> {
261
+ if (signal === undefined) return
262
+ if (signal.aborted) throw new ScanAborted()
263
+ await new Promise<void>((resolve) => setImmediate(resolve))
264
+ if (signal.aborted) throw new ScanAborted()
265
+ }
266
+
267
+ function kindOf(file: string): ContextKind {
268
+ const base = path.basename(file).toLowerCase()
269
+ if (base === "agents.md") return "agents"
270
+ if (base === "spec.md") return "spec"
271
+ if (base === "claude.md") return "claude"
272
+ return "other"
273
+ }
274
+
275
+ function relative(root: string, file: string): string {
276
+ const value = path.relative(root, file)
277
+ return (value === "" ? path.basename(file) : value).split(path.sep).join("/")
278
+ }
279
+
280
+ /** Cut a detail that echoes source text; a long secret must not be copied out. */
281
+ function clip(value: string): string {
282
+ return value.length > MAX_DETAIL_LENGTH ? `${value.slice(0, MAX_DETAIL_LENGTH - 3)}...` : value
283
+ }
284
+
285
+ function estimateTokens(text: string): number {
286
+ const chars = codePointLength(text)
287
+ const cyrillic = (text.match(/[\u0400-\u04FF]/g) ?? []).length
288
+ return Math.round(cyrillic / 3 + (chars - cyrillic) / 4)
289
+ }
290
+
291
+ /**
292
+ * Number of Unicode code points, counted without materializing `[...text]`
293
+ * (a 2 MB line would otherwise allocate one array entry per character).
294
+ */
295
+ function codePointLength(text: string): number {
296
+ let length = 0
297
+ for (const _ of text) length += 1
298
+ return length
299
+ }
300
+
301
+ /** `..` or `../x` is outside; `..foo` is a normal in-root name. */
302
+ function isInside(root: string, candidate: string): boolean {
303
+ const from = process.platform === "win32" ? path.resolve(root).toLowerCase() : path.resolve(root)
304
+ const to = process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate)
305
+ const inside = path.relative(from, to)
306
+ if (inside === "") return true
307
+ if (path.isAbsolute(inside)) return false
308
+ return inside !== ".." && !inside.startsWith(`..${path.sep}`)
309
+ }
310
+
311
+ /** Canonical path of an existing entry; a missing path falls back to the lexical one. */
312
+ async function canonical(target: string): Promise<string> {
313
+ try {
314
+ return await realpath(target)
315
+ } catch {
316
+ return path.resolve(target)
317
+ }
318
+ }
319
+
320
+ /** Chunk size of a capped read; memory stays bounded by the cap, not the file. */
321
+ const READ_CHUNK_BYTES = 64 * 1024
322
+
323
+ /**
324
+ * Read at most `maxBytes + 1` bytes through a FileHandle. `stat` is only the fast
325
+ * path with the exact size for the message: the authoritative cap is enforced
326
+ * here, so a file that grows between the `stat` and the read (TOCTOU) is refused
327
+ * with its observed size instead of being loaded whole. Exported for the cap
328
+ * test; it is an implementation detail, not part of the tool contract.
329
+ */
330
+ export async function readCappedFile(
331
+ file: string,
332
+ maxBytes: number,
333
+ ): Promise<{ readonly text: string } | { readonly bytes: number }> {
334
+ const handle = await open(file, "r")
335
+ try {
336
+ const chunks: Buffer[] = []
337
+ let total = 0
338
+ while (total <= maxBytes) {
339
+ const chunk = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, maxBytes + 1 - total))
340
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null)
341
+ if (bytesRead === 0) break
342
+ total += bytesRead
343
+ chunks.push(chunk.subarray(0, bytesRead))
344
+ if (total > maxBytes) return { bytes: total }
345
+ }
346
+ return { text: Buffer.concat(chunks).toString("utf8") }
347
+ } finally {
348
+ await handle.close()
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Normalize a link destination into a local, checkable path.
354
+ * Returns `undefined` for anything that is not a local relative path: URI schemes
355
+ * (`https:`, `ftp:`, `file:`, `ssh:`), protocol-relative `//host/…`, backslash
356
+ * paths and UNC shares (`\\host\share` — touching one would block on SMB),
357
+ * angle-bracketed or whitespace-carrying destinations and ellipses.
358
+ */
359
+ function localLinkTarget(raw: string): string | undefined {
360
+ let target = raw.trim()
361
+ if (target.startsWith("<") && target.endsWith(">")) target = target.slice(1, -1).trim()
362
+ if (target === "") return undefined
363
+ if (target.includes("\\")) return undefined
364
+ if (target.startsWith("//")) return undefined
365
+ if (URI_SCHEME.test(target)) return undefined
366
+ if (/[\s<>{}|$`"']/.test(target)) return undefined
367
+ if (target.includes("...") || target.includes("…")) return undefined
368
+ const withoutAnchor = target.split(/[?#]/)[0]
369
+ if (withoutAnchor === "") return undefined
370
+ try {
371
+ return decodeURIComponent(withoutAnchor)
372
+ } catch {
373
+ return undefined
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Target files under a directory, depth-first, depth ≤ 3. Every entry is
379
+ * resolved through `realpath`; an entry whose real path is outside the real
380
+ * root is skipped, so a junction/symlink cannot pull the scan (or a finding)
381
+ * out of the workspace. The cycle guard also uses real paths: a link to an
382
+ * ancestor stops at the visited set instead of recursing forever.
383
+ *
384
+ * The walk keeps a shared state instead of returning arrays: the file list is
385
+ * bounded by `maxFiles` and refuses *early* (the walk stops on the first file
386
+ * above the limit instead of expanding the whole tree), and every directory cut
387
+ * by the depth limit is counted, so the caller can report it instead of
388
+ * dropping files silently.
389
+ */
390
+ interface CollectState {
391
+ readonly files: string[]
392
+ readonly maxFiles: number
393
+ /** Directories below the depth limit that were not scanned. */
394
+ skippedDepth: number
395
+ /** Entries whose real path resolves outside the root (junction/symlink). */
396
+ readonly skippedLinks: string[]
397
+ }
398
+
399
+ async function collectFiles(
400
+ start: string,
401
+ realRoot: string,
402
+ signal: AbortSignal | undefined,
403
+ state: CollectState,
404
+ depth = 0,
405
+ visited = new Set<string>(),
406
+ ): Promise<void> {
407
+ if (depth > 3) {
408
+ state.skippedDepth += 1
409
+ return
410
+ }
411
+ if (signal?.aborted) throw new ScanAborted()
412
+ const identity = await canonical(start)
413
+ if (!isInside(realRoot, identity)) return
414
+ if (visited.has(identity)) return
415
+ visited.add(identity)
416
+ const entries = await readdir(start, { withFileTypes: true })
417
+ const push = (file: string): void => {
418
+ state.files.push(file)
419
+ if (state.files.length > state.maxFiles) throw new FilesLimitExceeded()
420
+ }
421
+ for (const entry of entries) {
422
+ if (signal?.aborted) throw new ScanAborted()
423
+ const full = path.join(start, entry.name)
424
+ const skipOutside = async (candidate: string): Promise<boolean> => {
425
+ if (isInside(realRoot, await canonical(candidate))) return false
426
+ // A junction/symlink that resolves outside the root is never scanned; the
427
+ // skip is recorded so the caller can report it instead of dropping it.
428
+ state.skippedLinks.push(candidate)
429
+ return true
430
+ }
431
+ if (entry.isSymbolicLink()) {
432
+ // A junction/symlink with a skipped name (`node_modules`, `dist`, `vendor`, …)
433
+ // is excluded by name, exactly like a real directory of that name: it is not
434
+ // reported as an out-of-root link, because the name already says it is not a
435
+ // workspace source (on Windows/pnpm `node_modules` is a junction by default).
436
+ if (SKIP.has(entry.name)) continue
437
+ let info: Awaited<ReturnType<typeof stat>>
438
+ try {
439
+ info = await stat(full)
440
+ } catch {
441
+ continue // dangling link
442
+ }
443
+ if (await skipOutside(full)) continue
444
+ if (info.isFile()) {
445
+ if (TARGET_NAME.test(entry.name)) push(full)
446
+ } else if (info.isDirectory() && !SKIP.has(entry.name)) {
447
+ await collectFiles(full, realRoot, signal, state, depth + 1, visited)
448
+ }
449
+ continue
450
+ }
451
+ if (entry.isDirectory()) {
452
+ if (SKIP.has(entry.name)) continue
453
+ if (await skipOutside(full)) continue
454
+ await collectFiles(full, realRoot, signal, state, depth + 1, visited)
455
+ } else if (entry.isFile() && TARGET_NAME.test(entry.name)) {
456
+ if (await skipOutside(full)) continue
457
+ push(full)
458
+ }
459
+ }
460
+ }
461
+
462
+ interface TargetBatch {
463
+ readonly files: string[]
464
+ /** Directories below the depth limit; reported as an INFO finding. */
465
+ readonly skippedDepth: number
466
+ /** In-root entries whose real path resolves outside the root; reported as INFO. */
467
+ readonly skippedLinks: readonly string[]
468
+ }
469
+
470
+ async function resolveTargets(
471
+ root: string,
472
+ target: string | undefined,
473
+ maxFiles: number,
474
+ realRoot: string,
475
+ signal: AbortSignal | undefined,
476
+ ): Promise<TargetBatch | ContextLintFailure> {
477
+ const rootAbsolute = path.resolve(root)
478
+ const start =
479
+ target === undefined
480
+ ? rootAbsolute
481
+ : path.isAbsolute(target)
482
+ ? path.normalize(target)
483
+ : path.resolve(rootAbsolute, target)
484
+ // Lexical fast path: a `../` target never reaches the filesystem.
485
+ if (target !== undefined && !isInside(rootAbsolute, start)) {
486
+ return {
487
+ ok: false,
488
+ schema: "context-lint-failure/v1",
489
+ code: "E_OUT_OF_ROOT",
490
+ message: `target is outside the workspace root: ${start}`,
491
+ path: start,
492
+ }
493
+ }
494
+ let info: Awaited<ReturnType<typeof stat>>
495
+ try {
496
+ info = await stat(start)
497
+ } catch {
498
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_NOT_FOUND", message: `target not found: ${start}` }
499
+ }
500
+ // Authoritative containment: a symlink/junction may lexically look in-root and
501
+ // still resolve outside it.
502
+ const resolvedStart = await canonical(start)
503
+ if (!isInside(realRoot, resolvedStart)) {
504
+ return {
505
+ ok: false,
506
+ schema: "context-lint-failure/v1",
507
+ code: "E_OUT_OF_ROOT",
508
+ message: `target resolves outside the workspace root: ${start} -> ${resolvedStart}`,
509
+ path: start,
510
+ }
511
+ }
512
+ if (info.isFile()) return { files: [start], skippedDepth: 0, skippedLinks: [] }
513
+ if (!info.isDirectory())
514
+ return {
515
+ ok: false,
516
+ schema: "context-lint-failure/v1",
517
+ code: "E_INPUT",
518
+ message: `target is neither a file nor a directory: ${start}`,
519
+ }
520
+ const state: CollectState = { files: [], maxFiles, skippedDepth: 0, skippedLinks: [] }
521
+ try {
522
+ await collectFiles(start, realRoot, signal, state)
523
+ } catch (error) {
524
+ if (error instanceof FilesLimitExceeded) {
525
+ return {
526
+ ok: false,
527
+ schema: "context-lint-failure/v1",
528
+ code: "E_LIMIT_FILES",
529
+ message: `more than ${maxFiles} files under ${start}; limit ${maxFiles}`,
530
+ path: start,
531
+ files: state.files.length,
532
+ max: maxFiles,
533
+ }
534
+ }
535
+ if (error instanceof ScanAborted || signal?.aborted) {
536
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
537
+ }
538
+ return {
539
+ ok: false,
540
+ schema: "context-lint-failure/v1",
541
+ code: "E_IO",
542
+ message: `cannot read ${start}: ${error instanceof Error ? error.message : String(error)}`,
543
+ }
544
+ }
545
+ state.files.sort()
546
+ state.skippedLinks.sort()
547
+ return { files: state.files, skippedDepth: state.skippedDepth, skippedLinks: state.skippedLinks }
548
+ }
549
+
550
+ /** Length of the backtick run that starts at `index`. */
551
+ function backtickRun(text: string, index: number): number {
552
+ let run = 1
553
+ while (text[index + run] === "`") run += 1
554
+ return run
555
+ }
556
+
557
+ /**
558
+ * End index (inclusive) of an HTML open tag `<name …>` starting at `index`, or
559
+ * `undefined` when this position is not that tag. `<code>`/`<code class=…>` and
560
+ * `<pre>`/`<pre class=…>` match; `<codes>` does not.
561
+ */
562
+ function htmlOpenTagEnd(text: string, index: number, name: string): number | undefined {
563
+ if (text.slice(index, index + name.length + 1).toLowerCase() !== `<${name}`) return undefined
564
+ const next = text[index + name.length + 1]
565
+ if (next !== ">" && next !== undefined && !/\s/.test(next)) return undefined
566
+ const close = text.indexOf(">", index + name.length + 1)
567
+ return close === -1 ? undefined : close
568
+ }
569
+
570
+ /**
571
+ * End index (inclusive) of an HTML close tag `</name …>` starting at `index`,
572
+ * or `undefined` when this position is not that tag. Matching is
573
+ * case-insensitive and only whitespace may separate the name from `>`, so
574
+ * `</code >` and `</PRE\t>` close their span while `</codes>` does not.
575
+ */
576
+ function htmlCloseTagEnd(text: string, index: number, name: string): number | undefined {
577
+ if (text.slice(index, index + name.length + 2).toLowerCase() !== `</${name}`) return undefined
578
+ const next = text[index + name.length + 2]
579
+ if (next !== undefined && next !== ">" && !/\s/.test(next)) return undefined
580
+ const close = text.indexOf(">", index + name.length + 2)
581
+ if (close < 0) return undefined
582
+ if (text.slice(index + name.length + 2, close).trim() !== "") return undefined
583
+ return close
584
+ }
585
+
586
+ /**
587
+ * Markup that makes `maskCode` rewrite a line: a backtick, an HTML comment or a
588
+ * `<pre>`/`<code>` open tag (any letter case; a false positive such as
589
+ * `<prefix` only costs the slow path). A line without it masks to itself.
590
+ */
591
+ const MASK_MARKUP = /<!--|<pre|<code/i
592
+
593
+ /**
594
+ * Result of one mask pass: the masked lines plus the 1-based line where a state
595
+ * that swallows the rest of the file is still open at EOF. One parser reports the
596
+ * masking state, so a marker inside a fence/comment/`<pre>` is never mistaken for
597
+ * an unclosed one.
598
+ */
599
+ interface MaskedLines {
600
+ readonly masked: string[]
601
+ /** A fenced block still open at EOF. */
602
+ readonly unclosedFenceLine?: number
603
+ /** A `<pre>` span still open at EOF. */
604
+ readonly unclosedPreLine?: number
605
+ /** An HTML comment still open at EOF. */
606
+ readonly unclosedCommentLine?: number
607
+ }
608
+
609
+ interface MaskOptions {
610
+ readonly inlineCode: boolean
611
+ readonly indentedCode?: boolean
612
+ /** Mask HTML comments (default `true`); the commands check reads code with comments visible. */
613
+ readonly comments?: boolean
614
+ }
615
+
616
+ /**
617
+ * Replace code blocks, `<pre>`/`<code>` spans, indented code and (when
618
+ * `options.inlineCode` is set) inline code and HTML comments with spaces,
619
+ * keeping the line count and every line length. A fence opened with N markers
620
+ * closes only with at least N markers of the same character; a code span
621
+ * opened with a run of N backticks closes only on a run of exactly N, so
622
+ * ``a, b`` is one span, and a run without a closing match in the same line is
623
+ * literal text (CommonMark), not a mask to the end of the line.
624
+ * `options.indentedCode` (default `true`) masks 4-space indented code;
625
+ * non-goals counting turns it off to accept its 4-space bullets.
626
+ * A `<pre>…</pre>` span is masked in place, preserving the text before and
627
+ * after it; an unclosed `<pre>` masks the following lines too, but never the
628
+ * current line prefix. The same pass reports which of those states is still open
629
+ * at EOF, so the caller can flag the masked remainder instead of passing it.
630
+ */
631
+ function maskCode(lines: readonly string[], options: MaskOptions): MaskedLines {
632
+ const masked: string[] = []
633
+ let fence: { readonly char: string; readonly length: number; readonly line: number } | undefined
634
+ let inComment = false
635
+ let commentFrom: number | undefined
636
+ let inPre = false
637
+ let preFrom: number | undefined
638
+ let inIndented = false
639
+ let previousBlank = true
640
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
641
+ const line = lines[lineIndex]
642
+ const lineNumber = lineIndex + 1
643
+ const blank = line.trim() === ""
644
+ if (fence !== undefined) {
645
+ const close = new RegExp(`^\\s*${fence.char}{${fence.length},}\\s*$`)
646
+ if (close.test(line)) fence = undefined
647
+ masked.push(" ".repeat(line.length))
648
+ previousBlank = false
649
+ continue
650
+ }
651
+ if (!inComment && !inPre) {
652
+ // CommonMark indented code: four spaces (or a tab) after a blank line.
653
+ const indented = options.indentedCode !== false && /^(?: {4,}|\t)/.test(line)
654
+ if (indented && (inIndented || previousBlank)) {
655
+ inIndented = true
656
+ masked.push(" ".repeat(line.length))
657
+ previousBlank = false
658
+ continue
659
+ }
660
+ if (!indented && !blank) inIndented = false
661
+ const open = /^\s*(`{3,}|~{3,})/.exec(line)
662
+ if (open !== null) {
663
+ fence = { char: open[1][0], length: open[1].length, line: lineNumber }
664
+ masked.push(" ".repeat(line.length))
665
+ previousBlank = false
666
+ continue
667
+ }
668
+ }
669
+ // Fast path: outside a fence/comment/`<pre>` span, a line with no backtick
670
+ // and no `<pre>`/`<code>`/`<!--` masks to itself. Reuse the original string
671
+ // (no per-character rebuild, no copy); the state above was already updated
672
+ // and `previousBlank` is set exactly as the slow path would.
673
+ if (!inComment && !inPre && !line.includes("`") && !MASK_MARKUP.test(line)) {
674
+ masked.push(line)
675
+ previousBlank = blank
676
+ continue
677
+ }
678
+ let out = ""
679
+ let inCodeTag = false
680
+ // Inline code spans: a run of N backticks opens a span only when a later run
681
+ // of exactly N backticks exists on the same line (CommonMark). A run with no
682
+ // closing match stays literal text, so a stray backtick cannot mask the rest
683
+ // of the line and hide a link behind it. Runs are indexed per line and looked
684
+ // up by binary search, so the scan stays O(line length · log runs) instead of
685
+ // rescanning the line for every unmatched run.
686
+ const runStarts = new Map<number, number[]>()
687
+ if (options.inlineCode) {
688
+ let at = line.indexOf("`")
689
+ while (at >= 0) {
690
+ const run = backtickRun(line, at)
691
+ const list = runStarts.get(run)
692
+ if (list === undefined) runStarts.set(run, [at])
693
+ else list.push(at)
694
+ at = line.indexOf("`", at + run)
695
+ }
696
+ }
697
+ const closingRun = (run: number, after: number): number | undefined => {
698
+ const list = runStarts.get(run)
699
+ if (list === undefined) return undefined
700
+ let low = 0
701
+ let high = list.length
702
+ while (low < high) {
703
+ const middle = (low + high) >> 1
704
+ if (list[middle] <= after) low = middle + 1
705
+ else high = middle
706
+ }
707
+ return list[low]
708
+ }
709
+ for (let index = 0; index < line.length; index += 1) {
710
+ const character = line[index]
711
+ if (inPre) {
712
+ const end = htmlCloseTagEnd(line, index, "pre")
713
+ if (end !== undefined) {
714
+ out += " ".repeat(end - index + 1)
715
+ index = end
716
+ inPre = false
717
+ preFrom = undefined
718
+ } else {
719
+ out += " "
720
+ }
721
+ continue
722
+ }
723
+ if (inComment) {
724
+ if (line.startsWith("-->", index)) {
725
+ out += " "
726
+ index += 2
727
+ inComment = false
728
+ commentFrom = undefined
729
+ } else {
730
+ out += " "
731
+ }
732
+ continue
733
+ }
734
+ if (inCodeTag) {
735
+ const end = htmlCloseTagEnd(line, index, "code")
736
+ if (end !== undefined) {
737
+ out += " ".repeat(end - index + 1)
738
+ index = end
739
+ inCodeTag = false
740
+ } else {
741
+ out += " "
742
+ }
743
+ continue
744
+ }
745
+ if (character === "`" && options.inlineCode) {
746
+ const run = backtickRun(line, index)
747
+ const close = closingRun(run, index + run - 1)
748
+ if (close === undefined) {
749
+ // No closing run of this width: literal text, the line stays checked.
750
+ out += "`".repeat(run)
751
+ index += run - 1
752
+ continue
753
+ }
754
+ const end = close + run - 1
755
+ out += " ".repeat(end - index + 1)
756
+ index = end
757
+ continue
758
+ }
759
+
760
+ if (options.comments !== false && line.startsWith("<!--", index)) {
761
+ out += " "
762
+ index += 3
763
+ inComment = true
764
+ commentFrom ??= lineNumber
765
+ continue
766
+ }
767
+ if (options.inlineCode) {
768
+ const codeEnd = htmlOpenTagEnd(line, index, "code")
769
+ if (codeEnd !== undefined) {
770
+ out += " ".repeat(codeEnd - index + 1)
771
+ index = codeEnd
772
+ inCodeTag = true
773
+ continue
774
+ }
775
+ }
776
+ const preEnd = htmlOpenTagEnd(line, index, "pre")
777
+ if (preEnd !== undefined) {
778
+ out += " ".repeat(preEnd - index + 1)
779
+ index = preEnd
780
+ inPre = true
781
+ preFrom ??= lineNumber
782
+ continue
783
+ }
784
+ out += character
785
+ }
786
+ masked.push(out)
787
+ previousBlank = inPre ? false : blank
788
+ }
789
+ return {
790
+ masked,
791
+ ...(fence === undefined ? {} : { unclosedFenceLine: fence.line }),
792
+ ...(preFrom === undefined ? {} : { unclosedPreLine: preFrom }),
793
+ ...(commentFrom === undefined ? {} : { unclosedCommentLine: commentFrom }),
794
+ }
795
+ }
796
+
797
+ /** Fences, `<pre>`, comments and indented code only: AC/R cells keep their text. */
798
+ function maskFences(lines: readonly string[]): MaskedLines {
799
+ return maskCode(lines, { inlineCode: false })
800
+ }
801
+
802
+ /** Fences plus inline code and comments: links, vague phrases and placeholders. */
803
+ function maskAll(lines: readonly string[]): MaskedLines {
804
+ return maskCode(lines, { inlineCode: true })
805
+ }
806
+
807
+ /**
808
+ * Fences, `<pre>` and comments only: indented code stays visible, so a 4-space
809
+ * bullet under a non-goals label is counted as an item. Inline code keeps its
810
+ * text, like `maskFences`.
811
+ */
812
+ function maskSectionBlocks(lines: readonly string[]): MaskedLines {
813
+ return maskCode(lines, { inlineCode: false, indentedCode: false })
814
+ }
815
+
816
+ /** Heading level of a line, or `undefined` when it is not a heading. */
817
+ function headingLevel(line: string): number | undefined {
818
+ const match = HEADING_LEVEL.exec(line)
819
+ return match === null ? undefined : match[1].length
820
+ }
821
+
822
+ /**
823
+ * One finding sink per file. Both severities respect `maxFindingsPerFile`; the
824
+ * global `maxTotalFindings` cap is applied after the scan, with MAJOR findings
825
+ * prioritized. When a per-file cap fires, one marker is appended (a MAJOR
826
+ * marker for dropped MAJOR findings, an INFO marker for dropped MINOR/INFO), so
827
+ * the verdict and the closing list still reflect what exists. Checks stop
828
+ * pushing through the sink, so a 300k-line file stays bounded.
829
+ */
830
+ interface FindingSink {
831
+ readonly kept: Omit<ContextFinding, "id">[]
832
+ readonly truncated: boolean
833
+ readonly majorDropped: boolean
834
+ readonly minorDropped: boolean
835
+ push(finding: Omit<ContextFinding, "id">): void
836
+ }
837
+
838
+ function createSink(maxFindingsPerFile: number): FindingSink {
839
+ const kept: Omit<ContextFinding, "id">[] = []
840
+ let majors = 0
841
+ let minors = 0
842
+ let majorDropped = false
843
+ let minorDropped = false
844
+ return {
845
+ kept,
846
+ get truncated() {
847
+ return majorDropped || minorDropped
848
+ },
849
+ get majorDropped() {
850
+ return majorDropped
851
+ },
852
+ get minorDropped() {
853
+ return minorDropped
854
+ },
855
+ push(finding) {
856
+ if (finding.severity === "MAJOR") {
857
+ if (majors >= maxFindingsPerFile) {
858
+ majorDropped = true
859
+ return
860
+ }
861
+ majors += 1
862
+ kept.push(finding)
863
+ return
864
+ }
865
+ if (minors >= maxFindingsPerFile) {
866
+ minorDropped = true
867
+ return
868
+ }
869
+ minors += 1
870
+ kept.push(finding)
871
+ },
872
+ }
873
+ }
874
+
875
+ function checkBudget(
876
+ file: ContextFileResult,
877
+ sink: FindingSink,
878
+ maxLines: number,
879
+ maxTokens: number,
880
+ maxWords: number,
881
+ ): void {
882
+ if (file.kind === "agents" || file.kind === "claude") {
883
+ if (file.lines > maxLines) {
884
+ sink.push({
885
+ severity: "MAJOR",
886
+ kind: "budget",
887
+ evidence: `${file.path}:1`,
888
+ detail: `${file.lines} lines > ${maxLines} budget`,
889
+ fix: "Move procedures to docs/skills; keep commands, boundaries and pointers.",
890
+ })
891
+ } else if (file.lines > Math.round(maxLines * 0.75)) {
892
+ sink.push({
893
+ severity: "MINOR",
894
+ kind: "budget",
895
+ evidence: `${file.path}:1`,
896
+ detail: `${file.lines} lines is close to the ${maxLines}-line budget`,
897
+ fix: "Cut rules that are not used in most sessions.",
898
+ })
899
+ }
900
+ if (file.tokens_est > maxTokens) {
901
+ sink.push({
902
+ severity: "MAJOR",
903
+ kind: "budget",
904
+ evidence: `${file.path}:1`,
905
+ detail: `~${file.tokens_est} tokens > ${maxTokens} budget`,
906
+ fix: "Move long procedures to references; keep only high-signal rules.",
907
+ })
908
+ }
909
+ } else if (file.kind === "spec") {
910
+ if (file.words > maxWords) {
911
+ sink.push({
912
+ severity: "MAJOR",
913
+ kind: "budget",
914
+ evidence: `${file.path}:1`,
915
+ detail: `${file.words} words > ${maxWords} budget`,
916
+ fix: "Split the spec via a roadmap/spec-of-specs; keep one feature per file.",
917
+ })
918
+ } else if (file.words > Math.round(maxWords * 0.8)) {
919
+ sink.push({
920
+ severity: "MINOR",
921
+ kind: "budget",
922
+ evidence: `${file.path}:1`,
923
+ detail: `${file.words} words is close to the ${maxWords}-word budget`,
924
+ fix: "Remove restated context and implementation details.",
925
+ })
926
+ }
927
+ }
928
+ }
929
+
930
+ async function checkVague(
931
+ file: ContextFileResult,
932
+ lines: readonly string[],
933
+ sink: FindingSink,
934
+ signal: AbortSignal | undefined,
935
+ ): Promise<void> {
936
+ const seen = new Map<string, number>()
937
+ for (let index = 0; index < lines.length; index += 1) {
938
+ if (index % SECTION_ABORT_LINES === 0) await abortGate(signal)
939
+ for (const { term, pattern } of VAGUE) {
940
+ if (pattern.test(lines[index]) && !seen.has(term)) seen.set(term, index + 1)
941
+ }
942
+ }
943
+ if (seen.size >= 3) {
944
+ const [term, line] = [...seen.entries()][0] as [string, number]
945
+ sink.push({
946
+ severity: "MAJOR",
947
+ kind: "vague",
948
+ evidence: `${file.path}:${line}`,
949
+ detail: `${seen.size} distinct vague phrases (${[...seen.keys()].join(", ")}; first: ${term})`,
950
+ fix: "Replace each with a testable rule or delete it.",
951
+ })
952
+ return
953
+ }
954
+ for (const [term, line] of seen) {
955
+ sink.push({
956
+ severity: "MINOR",
957
+ kind: "vague",
958
+ evidence: `${file.path}:${line}`,
959
+ detail: `vague phrase "${term}"`,
960
+ fix: "Make it testable or delete it.",
961
+ })
962
+ }
963
+ }
964
+
965
+ async function checkDuplicates(
966
+ file: ContextFileResult,
967
+ masked: readonly string[],
968
+ sink: FindingSink,
969
+ signal: AbortSignal | undefined,
970
+ ): Promise<void> {
971
+ const seen = new Map<string, number>()
972
+ for (let index = 0; index < masked.length; index += 1) {
973
+ if (index % SECTION_ABORT_LINES === 0) await abortGate(signal)
974
+ // Code samples repeat on purpose: the mask blanks fences, inline code and
975
+ // comments, so a copy-pasted snippet is not a duplicate rule.
976
+ const line = masked[index].trim()
977
+ if (line.length < 40 || line.startsWith("|")) continue
978
+ const normalized = line.replace(/\s+/g, " ")
979
+ const first = seen.get(normalized)
980
+ if (first === undefined) {
981
+ seen.set(normalized, index + 1)
982
+ continue
983
+ }
984
+ // No inner duplicate cap: the finding sink bounds the output, and a cap here
985
+ // would silently hide duplicates.
986
+ sink.push({
987
+ severity: "MINOR",
988
+ kind: "duplicate",
989
+ evidence: `${file.path}:${index + 1}`,
990
+ detail: `duplicates line ${first}`,
991
+ fix: "Keep one copy; link to the source instead.",
992
+ })
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Canonical path of an existing entry, or `undefined` when it does not exist or
998
+ * cannot be resolved. Unlike `canonical`, a failure is not replaced by a
999
+ * lexical guess: the caller must not probe a path whose real parent is unknown.
1000
+ */
1001
+ async function existingRealPath(target: string): Promise<string | undefined> {
1002
+ try {
1003
+ return await realpath(target)
1004
+ } catch {
1005
+ return undefined
1006
+ }
1007
+ }
1008
+
1009
+ /** Whitespace test used by the link scanner; `\s` matches CommonMark spaces. */
1010
+ const LINK_WHITESPACE = /\s/
1011
+
1012
+ /** A UNC (`\\host\share`) or protocol-relative (`//host/x`) destination: never probed. */
1013
+ function isNetworkTarget(raw: string): boolean {
1014
+ const target = raw.trim()
1015
+ return target.startsWith("\\\\") || target.startsWith("//")
1016
+ }
1017
+
1018
+ function skipLinkWhitespace(line: string, index: number): number {
1019
+ let at = index
1020
+ while (at < line.length && LINK_WHITESPACE.test(line[at])) at += 1
1021
+ return at
1022
+ }
1023
+
1024
+ /** True when the bracket at `open` is escaped by an odd run of backslashes (prose). */
1025
+ function isEscapedOpen(line: string, open: number): boolean {
1026
+ let backslashes = 0
1027
+ while (open - backslashes - 1 >= 0 && line[open - backslashes - 1] === "\\") backslashes += 1
1028
+ return backslashes % 2 === 1
1029
+ }
1030
+
1031
+ /**
1032
+ * Targets of every `](target)` on one line in a single forward pass, with no
1033
+ * stack and no per-candidate scan to the end of the line: `indexOf("](")`
1034
+ * locates the next open, the target and the optional title are consumed
1035
+ * locally, and `i` either jumps past the closing `)` or past the rejected
1036
+ * `](`. Total work is O(line length): a line of `](` without `)` stops at the
1037
+ * end of the first target (break), and closed pairs advance past the `)`.
1038
+ * A target is the run up to whitespace or `)` (an escaped `](` is prose, and so
1039
+ * is a candidate whose enclosing `[` is escaped: `\[o1](m1.md)` is literal text
1040
+ * under CommonMark), `<…>` strips the angles (literal spaces are percent-encoded, because an
1041
+ * angle destination may contain them), and a `'…'`/`"…"` title may contain
1042
+ * `)`, `](` and escaped quotes. An unclosed angle or title rejects only its
1043
+ * own candidate and resumes at `open + 2`, so a later valid link on the same
1044
+ * line is still checked; only a non-angle target with no `)` left stops the
1045
+ * scan (no later link can close). The next `>` is cached, so a run of
1046
+ * `](<` without `>` stays linear instead of rescanning to the end.
1047
+ * Yields one target at a time instead of collecting a `targets[]` array, so a
1048
+ * single line never materializes every link it contains. Aborts are checked at
1049
+ * the start, at the end and every `LINK_SCAN_ABORT_INTERVAL` candidates; on an
1050
+ * abort the generator stops yielding and the caller sees `signal.aborted`.
1051
+ */
1052
+ function* scanLinkTargets(line: string, signal: AbortSignal | undefined): Generator<string> {
1053
+ const length = line.length
1054
+ let index = 0
1055
+ let checked = 0
1056
+ // Cached position of the first `>` at or after `angleFrom` (-1: none left).
1057
+ // Candidate cursors only grow, so a found close stays valid until it is
1058
+ // passed and a missing close stays missing; a run of unclosed `](<` costs one
1059
+ // scan to the end, not one per candidate.
1060
+ let angleFrom = -1
1061
+ let angleNext = -1
1062
+ // Position of the last `[` before the current candidate: an odd backslash run
1063
+ // before it (`\[o1](m1.md)`) makes the bracket literal, so the candidate is
1064
+ // prose. The pointer only moves forward over the line, so the check stays
1065
+ // linear.
1066
+ let bracketFrom = 0
1067
+ let bracketEscaped = false
1068
+ if (signal?.aborted) return
1069
+ while (index < length) {
1070
+ checked += 1
1071
+ if (checked >= LINK_SCAN_ABORT_INTERVAL) {
1072
+ if (signal?.aborted) return
1073
+ checked = 0
1074
+ }
1075
+ const open = line.indexOf("](", index)
1076
+ if (open < 0) break
1077
+ while (bracketFrom < open) {
1078
+ const at = line.indexOf("[", bracketFrom)
1079
+ if (at < 0 || at >= open) {
1080
+ bracketFrom = open
1081
+ break
1082
+ }
1083
+ bracketEscaped = isEscapedOpen(line, at)
1084
+ bracketFrom = at + 1
1085
+ }
1086
+ if (bracketEscaped) {
1087
+ index = open + 2
1088
+ continue
1089
+ }
1090
+ if (isEscapedOpen(line, open)) {
1091
+ index = open + 2
1092
+ continue
1093
+ }
1094
+ let cursor = open + 2
1095
+ let target: string
1096
+ if (line[cursor] === "<") {
1097
+ if (angleFrom < 0 || (angleNext >= 0 && angleNext < cursor)) {
1098
+ angleFrom = cursor
1099
+ angleNext = line.indexOf(">", cursor + 1)
1100
+ }
1101
+ if (angleNext < 0) {
1102
+ // Unclosed angle: this candidate is not a link, but the rest of the
1103
+ // line may still hold one.
1104
+ index = open + 2
1105
+ continue
1106
+ }
1107
+ // An unescaped `<` inside the angle destination makes it malformed
1108
+ // (CommonMark forbids `<` there). A later link's `<...>` produces one;
1109
+ // a literal `](` in a valid destination does not. Recover at the next
1110
+ // `](` instead of swallowing the later link.
1111
+ const nestedOpen = line.indexOf("<", cursor + 1)
1112
+ if (nestedOpen >= 0 && nestedOpen < angleNext) {
1113
+ index = open + 2
1114
+ continue
1115
+ }
1116
+ // An angle destination may contain raw spaces; encode them so the target
1117
+ // survives `localLinkTarget`'s whitespace rejection and decodes back.
1118
+ // Padding stays trimmed like the non-angle path (the target is trimmed
1119
+ // before the whitespace check there too).
1120
+ target = line
1121
+ .slice(cursor + 1, angleNext)
1122
+ .trim()
1123
+ .replace(/ /g, "%20")
1124
+ cursor = angleNext + 1
1125
+ } else {
1126
+ const start = cursor
1127
+ while (cursor < length && !LINK_WHITESPACE.test(line[cursor]) && line[cursor] !== ")") cursor += 1
1128
+ // No `)` can follow the end of the line: stop instead of rescanning.
1129
+ if (cursor >= length) break
1130
+ target = line.slice(start, cursor)
1131
+ }
1132
+ let after = skipLinkWhitespace(line, cursor)
1133
+ const quote = line[after]
1134
+ if (after < length && (quote === "'" || quote === '"')) {
1135
+ let at = after + 1
1136
+ while (at < length && line[at] !== quote) at += line[at] === "\\" ? 2 : 1
1137
+ if (at >= length) {
1138
+ // Unclosed title: reject this candidate only; later `](` may be valid.
1139
+ index = open + 2
1140
+ continue
1141
+ }
1142
+ after = skipLinkWhitespace(line, at + 1)
1143
+ }
1144
+ if (after < length && line[after] === ")") {
1145
+ yield target
1146
+ index = after + 1
1147
+ } else {
1148
+ index = open + 2
1149
+ }
1150
+ }
1151
+ if (signal?.aborted) return
1152
+ }
1153
+
1154
+ /**
1155
+ * Check local relative links. Cost and containment are bounded:
1156
+ * - the parser is a single forward state machine with no stack and no
1157
+ * per-candidate scan to the end of the line, so `](` without a closing `)`
1158
+ * cannot trigger quadratic backtracking;
1159
+ * - targets are consumed one at a time from the generator and resolved inline:
1160
+ * no `candidates[]` array and no per-candidate record survive, so memory does
1161
+ * not grow with the number of links. At most `MAX_LINK_CANDIDATES` candidates
1162
+ * are counted per file; exceeding the cap returns `truncated: true` and the
1163
+ * caller adds one MAJOR marker (the unverified remainder must never let the
1164
+ * verdict pass); the file itself is capped at 2 MB, so even a pathological
1165
+ * 50k-link list keeps only the current line's dedupe set plus the bounded
1166
+ * path caches;
1167
+ * - every `LINK_CHECK_YIELD` candidates the signal is checked and the event
1168
+ * loop is yielded to, and `signal.aborted` is checked per line before and
1169
+ * after the generator, so a timer-driven abort wins inside a long line too;
1170
+ * - the parent's real path is resolved (and cached) before `existsSync`; an
1171
+ * unknown or out-of-root parent is skipped, so a junction pointing outside
1172
+ * the root is never probed and never reported as a broken link; the real path
1173
+ * of an existing target is cached as well, so 20k anchors to one file cost
1174
+ * one `realpath`. Every cache keeps at most `LINK_CACHE_LIMIT` entries, so a
1175
+ * hostile list of 50k distinct parents/targets cannot grow them without
1176
+ * bound (a miss costs one extra probe, never a wrong finding);
1177
+ * There is no per-file cap on reported broken links: the finding sink and the
1178
+ * global cap replace overflow with a MAJOR marker instead of dropping MAJORs.
1179
+ */
1180
+ async function checkLinks(
1181
+ file: ContextFileResult,
1182
+ lines: readonly string[],
1183
+ root: string,
1184
+ realRoot: string,
1185
+ absolute: string,
1186
+ sink: FindingSink,
1187
+ signal: AbortSignal | undefined,
1188
+ ): Promise<{ readonly aborted: boolean; readonly truncated: boolean }> {
1189
+ const parentRealPaths = new Map<string, string | undefined>()
1190
+ const targetRealPaths = new Map<string, string | undefined>()
1191
+ const probed = new Map<string, boolean>()
1192
+ let candidates = 0
1193
+ let sinceYield = 0
1194
+ let candidatesTruncated = false
1195
+ for (let index = 0; index < lines.length; index += 1) {
1196
+ // N56: a timer abort must win at least between lines even if the current
1197
+ // line is short.
1198
+ if (signal?.aborted) return { aborted: true, truncated: candidatesTruncated }
1199
+ const line = lines[index]
1200
+ // Fast path: without `](` the scanner yields nothing, so most prose lines
1201
+ // never allocate a generator or a dedupe set.
1202
+ if (!line.includes("](")) continue
1203
+ // Dedupe is per line (`line:target` before): a repeated target on one line
1204
+ // is checked once, and the set dies with the line. Created on first use so
1205
+ // a link-free line allocates nothing.
1206
+ let seen: Set<string> | undefined
1207
+ for (const raw of scanLinkTargets(line, signal)) {
1208
+ const target = localLinkTarget(raw)
1209
+ if (target === undefined) {
1210
+ // A UNC or protocol-relative destination is never probed (a network path
1211
+ // would block on the SMB timeout); it is reported instead of dropped, so
1212
+ // an unchecked target never reads as a checked one. Ordinary URI schemes
1213
+ // (`https:`, `mailto:`) are out of scope and stay silent.
1214
+ if (isNetworkTarget(raw)) {
1215
+ sink.push({
1216
+ severity: "INFO",
1217
+ kind: "other",
1218
+ evidence: `${file.path}:${index + 1}`,
1219
+ detail: clip(`network path is outside the workspace root and was not checked: "${raw.trim()}"`),
1220
+ fix: "Point the link at a file inside the workspace or remove it.",
1221
+ })
1222
+ }
1223
+ continue
1224
+ }
1225
+ if (candidates >= MAX_LINK_CANDIDATES) {
1226
+ candidatesTruncated = true
1227
+ break
1228
+ }
1229
+ candidates += 1
1230
+ sinceYield += 1
1231
+ if (sinceYield >= LINK_CHECK_YIELD) {
1232
+ sinceYield = 0
1233
+ if (signal?.aborted) return { aborted: true, truncated: candidatesTruncated }
1234
+ // A macrotask boundary lets setTimeout-driven abort timers fire; microtasks
1235
+ // alone would starve them.
1236
+ await new Promise<void>((resolve) => setImmediate(resolve))
1237
+ if (signal?.aborted) return { aborted: true, truncated: candidatesTruncated }
1238
+ }
1239
+ if (seen?.has(target)) continue
1240
+ seen ??= new Set<string>()
1241
+ seen.add(target)
1242
+ const resolved = target.startsWith("/")
1243
+ ? path.join(root, target.slice(1))
1244
+ : path.resolve(path.dirname(absolute), target)
1245
+ // Only local, in-root paths reach the filesystem: a path outside the
1246
+ // workspace is not ours to check, and a network path would block on SMB.
1247
+ // Such a link is reported (INFO): silence would be indistinguishable from
1248
+ // "checked and valid".
1249
+ if (!isInside(root, resolved)) {
1250
+ sink.push({
1251
+ severity: "INFO",
1252
+ kind: "other",
1253
+ evidence: `${file.path}:${index + 1}`,
1254
+ detail: clip(`link target is outside the workspace root and was not checked: "${raw.trim()}"`),
1255
+ fix: "Point the link at a file inside the workspace or remove it.",
1256
+ })
1257
+ continue
1258
+ }
1259
+ const parent = path.dirname(resolved)
1260
+ let parentReal = parentRealPaths.get(parent)
1261
+ if (!parentRealPaths.has(parent)) {
1262
+ parentReal = await existingRealPath(parent)
1263
+ if (parentReal === undefined) {
1264
+ // A missing suffix cannot contain a junction: resolve the nearest
1265
+ // existing ancestor instead and probe only if it is still in-root.
1266
+ let ancestor = parent
1267
+ while (true) {
1268
+ const up = path.dirname(ancestor)
1269
+ if (up === ancestor) break
1270
+ ancestor = up
1271
+ if (!isInside(root, ancestor)) break
1272
+ const real = await existingRealPath(ancestor)
1273
+ if (real !== undefined) {
1274
+ parentReal = real
1275
+ break
1276
+ }
1277
+ }
1278
+ }
1279
+ if (parentRealPaths.size < LINK_CACHE_LIMIT) parentRealPaths.set(parent, parentReal)
1280
+ }
1281
+ // Unknown or out-of-root parent: do not probe the child (a junction could
1282
+ // otherwise pull `existsSync` outside the workspace). The skip is reported:
1283
+ // an unverified link must not read as a verified one.
1284
+ if (parentReal === undefined || !isInside(realRoot, parentReal)) {
1285
+ sink.push({
1286
+ severity: "INFO",
1287
+ kind: "other",
1288
+ evidence: `${file.path}:${index + 1}`,
1289
+ detail: clip(`link target is outside the workspace root and was not checked: "${raw.trim()}"`),
1290
+ fix: "Point the link at a file inside the workspace or remove it.",
1291
+ })
1292
+ continue
1293
+ }
1294
+ // The probe itself is cached per resolved path: 20k anchors to one file are
1295
+ // one probe; a broken target repeated on many lines is still reported per
1296
+ // candidate line below. The cache keeps at most LINK_CACHE_LIMIT entries,
1297
+ // so 50k distinct broken targets are not retained.
1298
+ let exists = probed.get(resolved)
1299
+ if (exists === undefined) {
1300
+ exists = existsSync(resolved)
1301
+ if (probed.size < LINK_CACHE_LIMIT) probed.set(resolved, exists)
1302
+ }
1303
+ if (exists) {
1304
+ // An existing symlink/junction inside the root can still resolve outside
1305
+ // it; such a target is not ours to check either. The real path is cached:
1306
+ // a list of 20k anchors to one file must not cost 20k `realpath` calls.
1307
+ let targetReal = targetRealPaths.get(resolved)
1308
+ if (!targetRealPaths.has(resolved)) {
1309
+ targetReal = await existingRealPath(resolved)
1310
+ if (targetRealPaths.size < LINK_CACHE_LIMIT) targetRealPaths.set(resolved, targetReal)
1311
+ }
1312
+ if (targetReal === undefined || !isInside(realRoot, targetReal)) {
1313
+ sink.push({
1314
+ severity: "INFO",
1315
+ kind: "other",
1316
+ evidence: `${file.path}:${index + 1}`,
1317
+ detail: clip(`link target could not be resolved inside the workspace and was not checked: "${raw.trim()}"`),
1318
+ fix: "Point the link at a file inside the workspace or remove it.",
1319
+ })
1320
+ continue
1321
+ }
1322
+ continue
1323
+ }
1324
+ sink.push({
1325
+ severity: "MAJOR",
1326
+ kind: "broken_link",
1327
+ evidence: `${file.path}:${index + 1}`,
1328
+ detail: clip(`missing path "${target}"`),
1329
+ fix: "Fix the link or remove it.",
1330
+ })
1331
+ }
1332
+ // The generator stops on its own abort check (every
1333
+ // LINK_SCAN_ABORT_INTERVAL characters); surface that as an abort here.
1334
+ if (signal?.aborted) return { aborted: true, truncated: candidatesTruncated }
1335
+ if (candidatesTruncated) break
1336
+ }
1337
+ return { aborted: false, truncated: candidatesTruncated }
1338
+ }
1339
+
1340
+ async function checkPlaceholders(
1341
+ file: ContextFileResult,
1342
+ lines: readonly string[],
1343
+ masked: readonly string[],
1344
+ sink: FindingSink,
1345
+ signal: AbortSignal | undefined,
1346
+ ): Promise<void> {
1347
+ let openQuestionsLevel: number | undefined
1348
+ for (let index = 0; index < lines.length; index += 1) {
1349
+ if (index % SECTION_ABORT_LINES === 0) await abortGate(signal)
1350
+ const raw = lines[index]
1351
+ // Masked text loses code and comments but keeps headings: a `## Open
1352
+ // questions` inside a fence must not open the allowance.
1353
+ const line = masked[index] ?? raw
1354
+ const level = headingLevel(line)
1355
+ if (level !== undefined) {
1356
+ if (OPEN_QUESTIONS_HEADING.test(line)) openQuestionsLevel = level
1357
+ else if (openQuestionsLevel !== undefined && level <= openQuestionsLevel) openQuestionsLevel = undefined
1358
+ }
1359
+ const openQuestions = openQuestionsLevel !== undefined
1360
+ for (const match of line.matchAll(PLACEHOLDER)) {
1361
+ const marker = /^(?:TODO|TBD|FIXME)$/i.test(match[0])
1362
+ if (marker && openQuestions) continue
1363
+ sink.push({
1364
+ severity: "MAJOR",
1365
+ kind: "other",
1366
+ evidence: `${file.path}:${index + 1}`,
1367
+ detail: clip(`placeholder "${match[0]}"`),
1368
+ fix: "Fill it or move it to an explicit open-questions block.",
1369
+ })
1370
+ break
1371
+ }
1372
+ }
1373
+ }
1374
+
1375
+ /**
1376
+ * Report every region the mask swallowed to the end of the file. The state comes
1377
+ * from the same `maskCode` pass that produced the masked lines, so a fence marker
1378
+ * inside a comment, `<pre>` or indented code is never mistaken for an unclosed
1379
+ * fence. INFO keeps the verdict unchanged (CommonMark says it is code) while
1380
+ * making the unchecked remainder visible.
1381
+ */
1382
+ function checkMaskedRegions(file: ContextFileResult, mask: MaskedLines, sink: FindingSink): void {
1383
+ const unclosed: readonly (readonly [string, number | undefined])[] = [
1384
+ ["fence", mask.unclosedFenceLine],
1385
+ ["<pre>", mask.unclosedPreLine],
1386
+ ["HTML comment", mask.unclosedCommentLine],
1387
+ ]
1388
+ for (const [marker, line] of unclosed) {
1389
+ if (line === undefined) continue
1390
+ sink.push({
1391
+ severity: "INFO",
1392
+ kind: "other",
1393
+ evidence: `${file.path}:${line}`,
1394
+ detail: `unclosed ${marker} at line ${line}; the rest of the file is masked and not checked`,
1395
+ fix: `Close the ${marker} so the following content is checked.`,
1396
+ })
1397
+ }
1398
+ }
1399
+
1400
+ function checkFirstLines(file: ContextFileResult, lines: readonly string[], sink: FindingSink): void {
1401
+ const head = lines.slice(0, 3).join("\n")
1402
+ if (/(?:19|20)\d{2}-\d{2}-\d{2}|\bv?\d+\.\d+\.\d+\b/.test(head)) {
1403
+ sink.push({
1404
+ severity: "MINOR",
1405
+ kind: "other",
1406
+ evidence: `${file.path}:1`,
1407
+ detail: "date or version in the first three lines",
1408
+ fix: "Move it down; it breaks prefix caching.",
1409
+ })
1410
+ }
1411
+ }
1412
+
1413
+ /**
1414
+ * Split an inline list at the top level only: never inside `(…)`, `[…]`, `{…}`,
1415
+ * a backtick span of any width (``…``), an HTML `<code …>…</code>` span or a
1416
+ * `<pre>…</pre>` span. Separators are `;`, `,`, `X and Y` / `X и Y` and a
1417
+ * numbered marker (`1.`/`2)`); a comma or semicolon inside brackets or code is
1418
+ * content.
1419
+ */
1420
+ function splitTopLevelItems(text: string): string[] {
1421
+ const parts: string[] = []
1422
+ let current = ""
1423
+ let depth = 0
1424
+ let ticks = 0
1425
+ let inCodeTag = false
1426
+ let inPre = false
1427
+ for (let index = 0; index < text.length; index += 1) {
1428
+ const character = text[index]
1429
+ if (inPre) {
1430
+ const end = htmlCloseTagEnd(text, index, "pre")
1431
+ if (end !== undefined) {
1432
+ current += text.slice(index, end + 1)
1433
+ index = end
1434
+ inPre = false
1435
+ } else {
1436
+ current += character
1437
+ }
1438
+ continue
1439
+ }
1440
+ if (inCodeTag) {
1441
+ const end = htmlCloseTagEnd(text, index, "code")
1442
+ if (end !== undefined) {
1443
+ current += text.slice(index, end + 1)
1444
+ index = end
1445
+ inCodeTag = false
1446
+ } else {
1447
+ current += character
1448
+ }
1449
+ continue
1450
+ }
1451
+ if (ticks > 0) {
1452
+ if (character === "`") {
1453
+ const run = backtickRun(text, index)
1454
+ current += text.slice(index, index + run)
1455
+ index += run - 1
1456
+ if (run === ticks) ticks = 0
1457
+ } else {
1458
+ current += character
1459
+ }
1460
+ continue
1461
+ }
1462
+ if (character === "`") {
1463
+ const run = backtickRun(text, index)
1464
+ current += text.slice(index, index + run)
1465
+ index += run - 1
1466
+ ticks = run
1467
+ continue
1468
+ }
1469
+ const preEnd = htmlOpenTagEnd(text, index, "pre")
1470
+ if (preEnd !== undefined) {
1471
+ current += text.slice(index, preEnd + 1)
1472
+ index = preEnd
1473
+ inPre = true
1474
+ continue
1475
+ }
1476
+ const codeEnd = htmlOpenTagEnd(text, index, "code")
1477
+ if (codeEnd !== undefined) {
1478
+ current += text.slice(index, codeEnd + 1)
1479
+ index = codeEnd
1480
+ inCodeTag = true
1481
+ continue
1482
+ }
1483
+ if (character === "(" || character === "[" || character === "{") depth += 1
1484
+ else if (character === ")" || character === "]" || character === "}") depth = Math.max(0, depth - 1)
1485
+ const previous = index === 0 ? " " : text[index - 1]
1486
+ if (depth === 0 && (character === ";" || character === ",")) {
1487
+ parts.push(current)
1488
+ current = ""
1489
+ continue
1490
+ }
1491
+ const joined = /^(?:and|и)\s+/i.exec(text.slice(index))
1492
+ if (depth === 0 && /\s/.test(previous) && joined !== null) {
1493
+ parts.push(current)
1494
+ current = ""
1495
+ index += joined[0].length - 1
1496
+ continue
1497
+ }
1498
+ // A numbered marker starts an item only at the start or after whitespace;
1499
+ // `AC1.1` and `v1.2` stay inside their item.
1500
+ const numbered = /^\d+[.)]\s+/.exec(text.slice(index))
1501
+ if (depth === 0 && /\s/.test(previous) && numbered !== null) {
1502
+ parts.push(current)
1503
+ current = ""
1504
+ index += numbered[0].length - 1
1505
+ continue
1506
+ }
1507
+ current += character
1508
+ }
1509
+ parts.push(current)
1510
+ return parts
1511
+ }
1512
+
1513
+ /** Split an inline `Non-goals: …` value into top-level items (`;`, `,`, `and`/`и`, `1)`). */
1514
+ function inlineItems(rest: string): string[] {
1515
+ const text = rest.trim()
1516
+ if (text === "" || NONE_ITEM.test(text)) return []
1517
+ return splitTopLevelItems(text)
1518
+ .map((item) =>
1519
+ item
1520
+ .replace(/^[-*+]\s+/, "")
1521
+ .replace(/[.!]+$/, "")
1522
+ .trim(),
1523
+ )
1524
+ .filter((item) => item !== "")
1525
+ }
1526
+
1527
+ /** Separator row of a table: `|---|:--:|`. */
1528
+ function isSeparatorRow(line: string): boolean {
1529
+ return /^\s*\|[\s:|-]+\|\s*$/.test(line)
1530
+ }
1531
+
1532
+ /**
1533
+ * Count top-level items in a section: bullets, numbered items, table rows and
1534
+ * paragraphs. A wrapped paragraph is one item (not one per line); a bullet is
1535
+ * top-level when it is not nested deeper than the list's base indent, so a
1536
+ * 2-space list under a bold label stays top-level. A bullet at 0–4 spaces is
1537
+ * an item; anything indented 5+ is code or a continuation, not an item.
1538
+ */
1539
+ function countSectionItems(lines: readonly string[], start: number): number {
1540
+ let count = 0
1541
+ let bulletIndent: number | undefined
1542
+ let paragraphOpen = false
1543
+ for (let index = start; index < lines.length; index += 1) {
1544
+ const raw = lines[index]
1545
+ if (HEADING.test(raw)) break
1546
+ const trimmed = raw.trim()
1547
+ if (trimmed === "" || isSeparatorRow(raw)) {
1548
+ paragraphOpen = false
1549
+ continue
1550
+ }
1551
+ const indent = raw.length - raw.trimStart().length
1552
+ if (TABLE_ROW.test(raw)) {
1553
+ // The header row of a table is not an item; its data rows are.
1554
+ paragraphOpen = false
1555
+ if (isSeparatorRow(lines[index + 1] ?? "")) continue
1556
+ count += 1
1557
+ continue
1558
+ }
1559
+ if (/^(?:[-*+]|\d+[.)])\s+/.test(raw.trimStart())) {
1560
+ paragraphOpen = false
1561
+ if (indent > 4) continue // code, not a list item
1562
+ bulletIndent = bulletIndent === undefined ? indent : Math.min(bulletIndent, indent)
1563
+ // A sub-bullet belongs to its parent item, not to the count.
1564
+ if (indent > bulletIndent + 1) continue
1565
+ count += 1
1566
+ continue
1567
+ }
1568
+ // An indented non-bullet line is a continuation of the previous item.
1569
+ if (indent >= 2) {
1570
+ paragraphOpen = false
1571
+ continue
1572
+ }
1573
+ // Consecutive prose lines are one paragraph, hence one item.
1574
+ if (!paragraphOpen) {
1575
+ count += 1
1576
+ paragraphOpen = true
1577
+ }
1578
+ }
1579
+ return count
1580
+ }
1581
+
1582
+ /**
1583
+ * A non-goals section: a heading (numbered too, alone or combined as
1584
+ * `Goal and non-goals`), an inline `Non-goals: A; B; C`, or a wrapped label
1585
+ * followed by bullets. The count is top-level items, 3–7 by contract; bullets
1586
+ * at 0–4 spaces under the label count, deeper indentation and fenced code do not.
1587
+ */
1588
+ async function countNonGoals(
1589
+ lines: readonly string[],
1590
+ signal: AbortSignal | undefined,
1591
+ ): Promise<{ readonly present: boolean; readonly count: number }> {
1592
+ for (let index = 0; index < lines.length; index += 1) {
1593
+ if (index % SECTION_ABORT_LINES === 0) await abortGate(signal)
1594
+ const line = lines[index]
1595
+ const heading = NON_GOALS_HEADING.exec(line)
1596
+ if (heading !== null) {
1597
+ const rest = (heading[1] ?? "").trim()
1598
+ if (rest !== "") return { present: true, count: inlineItems(rest).length }
1599
+ // A combined `Goal and non-goals` section may carry a separate label.
1600
+ for (let next = index + 1; next < lines.length; next += 1) {
1601
+ if (HEADING.test(lines[next])) break
1602
+ const label = NON_GOALS_INLINE.exec(lines[next])
1603
+ if (label === null) continue
1604
+ const inline = (label[1] ?? "").trim()
1605
+ if (inline !== "") return { present: true, count: inlineItems(inline).length }
1606
+ return { present: true, count: countSectionItems(lines, next + 1) }
1607
+ }
1608
+ return { present: true, count: countSectionItems(lines, index + 1) }
1609
+ }
1610
+ const inline = NON_GOALS_INLINE.exec(line)
1611
+ if (inline !== null) {
1612
+ const rest = (inline[1] ?? "").trim()
1613
+ if (rest !== "") return { present: true, count: inlineItems(rest).length }
1614
+ // A wrapped `Non-goals:` label: the bullets below are the items.
1615
+ return { present: true, count: countSectionItems(lines, index + 1) }
1616
+ }
1617
+ }
1618
+ return { present: false, count: 0 }
1619
+ }
1620
+
1621
+ /**
1622
+ * Cells of one Markdown table row, without the outer pipes. An escaped pipe
1623
+ * (`\|`) is cell content, not a separator.
1624
+ */
1625
+ function splitRow(line: string): string[] {
1626
+ let text = line.trim()
1627
+ if (text.startsWith("|")) text = text.slice(1)
1628
+ if (text.endsWith("|") && !text.endsWith("\\|")) text = text.slice(0, -1)
1629
+ const cells: string[] = []
1630
+ let current = ""
1631
+ for (let index = 0; index < text.length; index += 1) {
1632
+ const character = text[index]
1633
+ if (character === "\\" && text[index + 1] === "|") {
1634
+ current += "|"
1635
+ index += 1
1636
+ continue
1637
+ }
1638
+ if (character === "|") {
1639
+ cells.push(current.trim())
1640
+ current = ""
1641
+ continue
1642
+ }
1643
+ current += character
1644
+ }
1645
+ cells.push(current.trim())
1646
+ return cells
1647
+ }
1648
+
1649
+ interface TableBlock {
1650
+ readonly start: number
1651
+ readonly end: number
1652
+ /** Header cells when the block has a separator row; `undefined` when headerless. */
1653
+ readonly header: readonly string[] | undefined
1654
+ readonly rows: readonly { readonly line: number; readonly cells: readonly string[] }[]
1655
+ }
1656
+
1657
+ /** Every contiguous table block, with its header row (when it has one). */
1658
+ function tableBlocks(lines: readonly string[]): TableBlock[] {
1659
+ const blocks: TableBlock[] = []
1660
+ let index = 0
1661
+ while (index < lines.length) {
1662
+ if (!TABLE_ROW.test(lines[index])) {
1663
+ index += 1
1664
+ continue
1665
+ }
1666
+ const start = index
1667
+ const collected: { line: number; cells: string[] }[] = []
1668
+ while (index < lines.length && TABLE_ROW.test(lines[index])) {
1669
+ collected.push({ line: index, cells: splitRow(lines[index]) })
1670
+ index += 1
1671
+ }
1672
+ const hasHeader = collected.length >= 2 && isSeparatorRow(lines[collected[1].line])
1673
+ blocks.push({
1674
+ start,
1675
+ end: index - 1,
1676
+ header: hasHeader ? collected[0].cells : undefined,
1677
+ rows: hasHeader ? collected.slice(2) : collected,
1678
+ })
1679
+ }
1680
+ return blocks
1681
+ }
1682
+
1683
+ interface AcTable {
1684
+ /** Column index of the AC id. */
1685
+ readonly ac: number
1686
+ /** Column index of the requirement id, or -1. */
1687
+ readonly r: number
1688
+ /** Column index of the test reference. */
1689
+ readonly test: number
1690
+ /** Number of header cells; a shorter row is malformed. */
1691
+ readonly columns: number
1692
+ }
1693
+
1694
+ const AC_HEADER = /^ac(?:\s*id)?$/i
1695
+ const AC_HEADER_NUMBERED = /^ac\d+$/i
1696
+ const AC_ID = /^AC\d+(?:\.\d+)?$/
1697
+
1698
+ /** Cell text without surrounding backticks: `` `test_export` `` → `test_export`. */
1699
+ function cellText(cell: string | undefined): string {
1700
+ const trimmed = (cell ?? "").trim()
1701
+ const unwrapped = /^`+([\s\S]*?)`+$/.exec(trimmed)
1702
+ return (unwrapped === null ? trimmed : unwrapped[1]).trim()
1703
+ }
1704
+
1705
+ /** A Test cell counts as missing when blank, `-` or an em dash. */
1706
+ function hasTestValue(cell: string | undefined): boolean {
1707
+ const value = cellText(cell)
1708
+ return value !== "" && value !== "-" && value !== "—"
1709
+ }
1710
+
1711
+ interface AcScan {
1712
+ /** Valid AC tables: header has AC, Test and Given/When/Then. */
1713
+ readonly tables: readonly { readonly table: AcTable; readonly block: TableBlock }[]
1714
+ /** Headed blocks with an AC column but no Test column: a MAJOR, never a pass. */
1715
+ readonly withoutTest: readonly { readonly block: TableBlock; readonly r: number }[]
1716
+ /** Rows of headerless blocks whose first cell is an AC id. */
1717
+ readonly headerless: readonly { readonly line: number; readonly cells: readonly string[] }[]
1718
+ }
1719
+
1720
+ /**
1721
+ * Locate AC material by block, not by row shape. An AC table needs a header with
1722
+ * an `AC`/`AC1` cell, a `Test`/`Tests` cell and `Given`/`When`/`Then`; a
1723
+ * traceability table (`| AC | R | Task | Test |`) is not an AC table. A headed
1724
+ * table with AC ids but no Test column is recorded separately, and headerless
1725
+ * AC-id rows are collected per block, so a headed traceability table cannot
1726
+ * masquerade as headerless acceptance criteria.
1727
+ */
1728
+ function scanAcTables(lines: readonly string[]): AcScan {
1729
+ const tables: { table: AcTable; block: TableBlock }[] = []
1730
+ const withoutTest: { block: TableBlock; r: number }[] = []
1731
+ const headerless: { line: number; cells: readonly string[] }[] = []
1732
+ for (const block of tableBlocks(lines)) {
1733
+ const header = block.header
1734
+ if (header === undefined) {
1735
+ for (const row of block.rows) {
1736
+ if (AC_ID.exec(cellText(row.cells[0])) !== null) headerless.push({ line: row.line, cells: row.cells })
1737
+ }
1738
+ continue
1739
+ }
1740
+ const cells = header.map((cell) => cellText(cell).toLowerCase())
1741
+ const ac = cells.findIndex((cell) => AC_HEADER.test(cell) || AC_HEADER_NUMBERED.test(cell))
1742
+ if (ac === -1) continue
1743
+ const r = cells.findIndex((cell) => /^(?:r|req|requirement|требование)$/.test(cell))
1744
+ const test = cells.findIndex((cell) => /\btests?\b|\bтест/.test(cell))
1745
+ const scenario = cells.some((cell) => /^(?:given|when|then|дано|когда|тогда)\b/.test(cell))
1746
+ if (test === -1) {
1747
+ // Given/When/Then without Test is a defective AC table; no scenario and no
1748
+ // Test is a traceability table, not acceptance criteria.
1749
+ if (scenario) withoutTest.push({ block, r })
1750
+ continue
1751
+ }
1752
+ if (!scenario) continue
1753
+ tables.push({ table: { ac, r, test, columns: cells.length }, block })
1754
+ }
1755
+ return { tables, withoutTest, headerless }
1756
+ }
1757
+
1758
+ /** Lines that belong to AC material: their R cells are not requirement declarations. */
1759
+ function acSkipLines(scan: AcScan): Set<number> {
1760
+ const skip = new Set<number>()
1761
+ // A defective AC table (no Test column) still maps R→AC: its R cells must not
1762
+ // be read as requirement declarations, or the table would declare its own R.
1763
+ for (const { block } of [...scan.tables, ...scan.withoutTest]) {
1764
+ for (let index = block.start; index <= block.end; index += 1) skip.add(index)
1765
+ }
1766
+ for (const row of scan.headerless) skip.add(row.line)
1767
+ return skip
1768
+ }
1769
+
1770
+ /**
1771
+ * Requirement ids: `R\d+` list lines and R cells of tables outside the AC
1772
+ * material. An AC R column maps requirements to criteria; it never declares
1773
+ * one, so `## Requirements` with only an AC table is empty. The list pattern
1774
+ * starts at `trimStart()`, so an N-space line costs one pass instead of the old
1775
+ * `^\s*…\s*` backtracking chain (O(N²) on a run of whitespace).
1776
+ */
1777
+ async function collectRequirements(
1778
+ lines: readonly string[],
1779
+ skip: ReadonlySet<number>,
1780
+ signal: AbortSignal | undefined,
1781
+ ): Promise<Map<string, number>> {
1782
+ const found = new Map<string, number>()
1783
+ for (let index = 0; index < lines.length; index += 1) {
1784
+ if (index % SECTION_ABORT_LINES === 0) await abortGate(signal)
1785
+ const line = lines[index]
1786
+ // Backticks around an id are markup: `` `R1` `` still declares R1.
1787
+ const plain = line.replace(/`/g, "")
1788
+ const listMatch = /^(?:[-*][ \t]*)?\*{0,2}(R\d+)(?!\d)/.exec(plain.trimStart())
1789
+ if (listMatch !== null) {
1790
+ if (!found.has(listMatch[1])) found.set(listMatch[1], index + 1)
1791
+ continue
1792
+ }
1793
+ if (!TABLE_ROW.test(line) || skip.has(index)) continue
1794
+ for (const cell of splitRow(line)) {
1795
+ const match = /^(R\d+)$/.exec(cellText(cell))
1796
+ if (match !== null && !found.has(match[1])) found.set(match[1], index + 1)
1797
+ }
1798
+ }
1799
+ return found
1800
+ }
1801
+
1802
+ /** Headerless AC rows: `AC | R | Given | When | Then | Test` (six cells). */
1803
+ const HEADERLESS_TEST_COLUMN = 5
1804
+ const HEADERLESS_COLUMNS = 6
1805
+
1806
+ async function checkAcRows(
1807
+ file: ContextFileResult,
1808
+ lines: readonly string[],
1809
+ sink: FindingSink,
1810
+ signal: AbortSignal | undefined,
1811
+ ): Promise<void> {
1812
+ if (file.kind !== "spec") return
1813
+ const scan = scanAcTables(lines)
1814
+ const requirements = await collectRequirements(lines, acSkipLines(scan), signal)
1815
+ const covered = new Set<string>()
1816
+ let hasRequirementColumn = false
1817
+ let hasHeaderlessR = false
1818
+ // A header-only AC table still declares the mapping contract, so R coverage
1819
+ // is checked even when the table has no data rows.
1820
+ let sawAc = scan.tables.length > 0 || scan.withoutTest.length > 0 || scan.headerless.length > 0
1821
+ // A headed table with an AC column and no Test column is a MAJOR on its own;
1822
+ // headerless detection must not hide it.
1823
+ for (const { block, r } of scan.withoutTest) {
1824
+ sink.push({
1825
+ severity: "MAJOR",
1826
+ kind: "missing_section",
1827
+ evidence: `${file.path}:${block.start + 1}`,
1828
+ detail: "AC table lacks Test column",
1829
+ fix: "Add the Test column; every AC row needs a test name or reference.",
1830
+ })
1831
+ if (r === -1) continue
1832
+ hasRequirementColumn = true
1833
+ for (const row of block.rows) {
1834
+ for (const match of cellText(row.cells[r]).matchAll(/R\d+/gi)) covered.add(match[0].toUpperCase())
1835
+ }
1836
+ }
1837
+ for (const { table, block } of scan.tables) {
1838
+ if (table.r !== -1) hasRequirementColumn = true
1839
+ for (const row of block.rows) {
1840
+ const id = AC_ID.exec(cellText(row.cells[table.ac]))
1841
+ if (id === null) continue // header, separator or a traceability row
1842
+ sawAc = true
1843
+ if (row.cells.length < table.columns) {
1844
+ sink.push({
1845
+ severity: "MINOR",
1846
+ kind: "missing_section",
1847
+ evidence: `${file.path}:${row.line + 1}`,
1848
+ detail: "AC row lacks columns",
1849
+ fix: "Use AC | R | Given | When | Then | Test.",
1850
+ })
1851
+ }
1852
+ if (table.r !== -1) {
1853
+ for (const match of cellText(row.cells[table.r]).matchAll(/R\d+/gi)) covered.add(match[0].toUpperCase())
1854
+ }
1855
+ if (!hasTestValue(row.cells[table.test])) {
1856
+ sink.push({
1857
+ severity: "MAJOR",
1858
+ kind: "missing_section",
1859
+ evidence: `${file.path}:${row.line + 1}`,
1860
+ detail: "AC row has no test",
1861
+ fix: "Add a test name or reference.",
1862
+ })
1863
+ }
1864
+ }
1865
+ }
1866
+ // Headerless rows are inspected even when a normal AC table exists: otherwise
1867
+ // a malformed row next to a valid table would pass silently.
1868
+ let headerless = 0
1869
+ for (const row of scan.headerless) {
1870
+ const cells = row.cells
1871
+ headerless += 1
1872
+ sawAc = true
1873
+ // The R column of a headerless row still proves coverage.
1874
+ const requirement = cellText(cells[1])
1875
+ if (/^R\d+$/.test(requirement)) {
1876
+ hasHeaderlessR = true
1877
+ covered.add(requirement.toUpperCase())
1878
+ }
1879
+ // A 5-cell row has no Test column at all; that is a missing test, not a pass.
1880
+ if (cells.length < HEADERLESS_COLUMNS || !hasTestValue(cells[HEADERLESS_TEST_COLUMN])) {
1881
+ sink.push({
1882
+ severity: "MAJOR",
1883
+ kind: "missing_section",
1884
+ evidence: `${file.path}:${row.line + 1}`,
1885
+ detail: "AC row has no test",
1886
+ fix: "Add a test name or reference.",
1887
+ })
1888
+ }
1889
+ }
1890
+ if (headerless > 0) {
1891
+ sink.push({
1892
+ severity: "MINOR",
1893
+ kind: "missing_section",
1894
+ evidence: `${file.path}:1`,
1895
+ detail: "AC table lacks header",
1896
+ fix: "Add the header AC | R | Given | When | Then | Test.",
1897
+ })
1898
+ }
1899
+ if (!sawAc) return
1900
+ if (!hasRequirementColumn && !hasHeaderlessR) {
1901
+ // Without an R column there is no R→AC mapping: report the structural gap
1902
+ // once instead of one false MAJOR per requirement.
1903
+ sink.push({
1904
+ severity: "MINOR",
1905
+ kind: "missing_section",
1906
+ evidence: `${file.path}:1`,
1907
+ detail: "AC table lacks R column",
1908
+ fix: "Add the R column and map every requirement to its AC rows.",
1909
+ })
1910
+ return
1911
+ }
1912
+ for (const [requirement, line] of requirements) {
1913
+ if (covered.has(requirement)) continue
1914
+ sink.push({
1915
+ severity: "MAJOR",
1916
+ kind: "missing_section",
1917
+ evidence: `${file.path}:${line}`,
1918
+ detail: `${requirement} has no acceptance criterion`,
1919
+ fix: "Add an AC row for it or drop the requirement.",
1920
+ })
1921
+ }
1922
+ }
1923
+
1924
+ /**
1925
+ * Lines that may carry a shell command: the body of a fenced/indented code
1926
+ * block, inline code spans, a table whose header names a Task/Задача, and the
1927
+ * body of a Commands/Команды section. Prose outside those regions is not a
1928
+ * command source, so "Make sure to be careful" cannot satisfy the commands
1929
+ * check; `make` additionally needs a target (`MAKE_TARGET`). An HTML comment is
1930
+ * not code: `<!-- npm install -->` must not pass the check, so a line that is
1931
+ * blank in the comment-aware mask but not in the code mask is skipped.
1932
+ */
1933
+ function commandSources(lines: readonly string[], fenceMasked: readonly string[]): string {
1934
+ const parts: string[] = []
1935
+ // Fences, `<pre>` and indented code only: a comment line is not blanked here,
1936
+ // so a commented command is not mistaken for a code block.
1937
+ const code = maskCode(lines, { inlineCode: false, comments: false }).masked
1938
+ let commandsLevel: number | undefined
1939
+ let taskTable = false
1940
+ for (let index = 0; index < lines.length; index += 1) {
1941
+ const raw = lines[index]
1942
+ const level = headingLevel(raw)
1943
+ if (level !== undefined) {
1944
+ if (COMMANDS_HEADING.test(raw)) commandsLevel = level
1945
+ else if (commandsLevel !== undefined && level <= commandsLevel) commandsLevel = undefined
1946
+ }
1947
+ taskTable = TABLE_ROW.test(raw) ? taskTable || TASK_TABLE_HEADER.test(raw) : false
1948
+ const fenced = raw.trim() !== "" && (code[index] ?? "").trim() === ""
1949
+ // A line that is blank in the comment-aware mask but not in the code mask is
1950
+ // comment content: `<!-- npm install -->` and a backticked command inside a
1951
+ // multi-line comment are not commands.
1952
+ const commented = !fenced && raw.trim() !== "" && (fenceMasked[index] ?? "").trim() === ""
1953
+ if (commented) continue
1954
+ if (fenced || commandsLevel !== undefined || taskTable) parts.push(raw)
1955
+ if (raw.includes("`")) {
1956
+ for (const match of raw.matchAll(/`+([^`\n]*)`+/g)) parts.push(match[1])
1957
+ }
1958
+ }
1959
+ return parts.join("\n")
1960
+ }
1961
+
1962
+ function hasCommand(commands: string): boolean {
1963
+ return SHELL_TOOL.test(commands) || MAKE_TARGET.test(commands)
1964
+ }
1965
+
1966
+ /**
1967
+ * Structural section checks. Commands/Boundaries/Verification/Definition of done
1968
+ * read the full mask (`masked`): a command or a check that exists only as inline
1969
+ * code is a sample, not a rule. Requirements and AC read `fenceMasked`: inline
1970
+ * code keeps its text there (`AC1.1`, `R1`). Non-goals read `sectionMasked`,
1971
+ * which also keeps 4-space bullets visible: a bullet under the label is an item,
1972
+ * not code. `lines` is the raw text: it is the only place where the body of a
1973
+ * fenced block is still visible for the commands check.
1974
+ */
1975
+ async function checkSections(
1976
+ file: ContextFileResult,
1977
+ lines: readonly string[],
1978
+ fenceMasked: readonly string[],
1979
+ sectionMasked: readonly string[],
1980
+ masked: readonly string[],
1981
+ sink: FindingSink,
1982
+ signal: AbortSignal | undefined,
1983
+ ): Promise<void> {
1984
+ const text = masked.join("\n")
1985
+ if (file.kind === "agents" || file.kind === "claude") {
1986
+ if (
1987
+ !masked.some((line) => COMMANDS_HEADING.test(line)) &&
1988
+ !TASK_TABLE_HEADER.test(text) &&
1989
+ !hasCommand(commandSources(lines, fenceMasked))
1990
+ ) {
1991
+ sink.push({
1992
+ severity: "MINOR",
1993
+ kind: "missing_section",
1994
+ evidence: `${file.path}:1`,
1995
+ detail: "no commands section",
1996
+ fix: "Add install/build/test/lint/run commands.",
1997
+ })
1998
+ }
1999
+ if (!BOUNDARIES.test(text)) {
2000
+ sink.push({
2001
+ severity: "MINOR",
2002
+ kind: "missing_section",
2003
+ evidence: `${file.path}:1`,
2004
+ detail: "no Never / Ask first boundaries",
2005
+ fix: "Add secrets, generated files, prod and migration boundaries.",
2006
+ })
2007
+ }
2008
+ if (!VERIFICATION.test(text)) {
2009
+ sink.push({
2010
+ severity: "MINOR",
2011
+ kind: "missing_section",
2012
+ evidence: `${file.path}:1`,
2013
+ detail: "no verification / definition of done",
2014
+ fix: "Add the check chain that must be green before done.",
2015
+ })
2016
+ }
2017
+ }
2018
+ if (file.kind === "spec") {
2019
+ const nonGoals = await countNonGoals(sectionMasked, signal)
2020
+ if (!nonGoals.present) {
2021
+ sink.push({
2022
+ severity: "MAJOR",
2023
+ kind: "missing_section",
2024
+ evidence: `${file.path}:1`,
2025
+ detail: "no non-goals",
2026
+ fix: "Add 3–7 things the feature deliberately does not do.",
2027
+ })
2028
+ } else if (nonGoals.count < 3 || nonGoals.count > 7) {
2029
+ sink.push({
2030
+ severity: "MAJOR",
2031
+ kind: "missing_section",
2032
+ evidence: `${file.path}:1`,
2033
+ detail: `${nonGoals.count} non-goals; expected 3–7 items`,
2034
+ fix: "List 3–7 things the feature deliberately does not do.",
2035
+ })
2036
+ }
2037
+ const scan = scanAcTables(fenceMasked)
2038
+ const requirements = await collectRequirements(fenceMasked, acSkipLines(scan), signal)
2039
+ // A heading alone is not a requirement set: at least one R line or R table
2040
+ // row outside the AC material must exist, otherwise a `## Requirements`
2041
+ // stub with an AC table R cell would pass.
2042
+ if (requirements.size === 0) {
2043
+ sink.push({
2044
+ severity: "MAJOR",
2045
+ kind: "missing_section",
2046
+ evidence: `${file.path}:1`,
2047
+ detail: "no numbered requirements",
2048
+ fix: "Add R1..Rn, one observable behavior per requirement.",
2049
+ })
2050
+ }
2051
+ // Acceptance evidence is block-aware: a valid AC table, a headed AC table
2052
+ // that is missing its Test column (reported by `checkAcRows`), or a
2053
+ // headerless AC-id row. A headed traceability table is none of these.
2054
+ const hasAcceptance = scan.tables.length > 0 || scan.withoutTest.length > 0 || scan.headerless.length > 0
2055
+ if (!hasAcceptance) {
2056
+ sink.push({
2057
+ severity: "MAJOR",
2058
+ kind: "missing_section",
2059
+ evidence: `${file.path}:1`,
2060
+ detail: "no acceptance criteria",
2061
+ fix: "Add Given/When/Then AC with a test per row.",
2062
+ })
2063
+ }
2064
+ if (!DEFINITION_OF_DONE.test(text)) {
2065
+ sink.push({
2066
+ severity: "MINOR",
2067
+ kind: "missing_section",
2068
+ evidence: `${file.path}:1`,
2069
+ detail: "no definition of done",
2070
+ fix: "Add a 5–8 item done checklist.",
2071
+ })
2072
+ }
2073
+ }
2074
+ }
2075
+
2076
+ /**
2077
+ * A top-level `and`/`и` join: never inside `(…)`, `[…]`, `{…}` or a code span.
2078
+ * `and` must be a whole word and `и` a separate word, so `Android` and `импорт`
2079
+ * do not match.
2080
+ */
2081
+ function topLevelJoin(text: string): boolean {
2082
+ let depth = 0
2083
+ let ticks = 0
2084
+ for (let index = 0; index < text.length; index += 1) {
2085
+ const character = text[index]
2086
+ if (character === "`") {
2087
+ const run = backtickRun(text, index)
2088
+ ticks = ticks === 0 ? run : run === ticks ? 0 : ticks
2089
+ index += run - 1
2090
+ continue
2091
+ }
2092
+ if (ticks > 0) continue
2093
+ if (character === "(" || character === "[" || character === "{") {
2094
+ depth += 1
2095
+ continue
2096
+ }
2097
+ if (character === ")" || character === "]" || character === "}") {
2098
+ depth = Math.max(0, depth - 1)
2099
+ continue
2100
+ }
2101
+ if (depth > 0 || index === 0) continue
2102
+ if (!/[\s,;:(]/.test(text[index - 1])) continue
2103
+ const rest = text.slice(index)
2104
+ if (/^and\b/i.test(rest)) return true
2105
+ if (/^и(?=$|[\s,;:.!?—–-])/i.test(rest)) return true
2106
+ }
2107
+ return false
2108
+ }
2109
+
2110
+ async function checkAndChains(
2111
+ file: ContextFileResult,
2112
+ lines: readonly string[],
2113
+ sink: FindingSink,
2114
+ signal: AbortSignal | undefined,
2115
+ ): Promise<void> {
2116
+ if (file.kind !== "spec") return
2117
+ for (let index = 0; index < lines.length; index += 1) {
2118
+ if (index % SECTION_ABORT_LINES === 0) await abortGate(signal)
2119
+ const line = lines[index]
2120
+ // `trimStart()` + a bounded run: the old `^\s*[-*]?\s*\*{0,2}` chain was
2121
+ // O(N²) on a line of N spaces.
2122
+ if (!/^(?:[-*][ \t]*)?\*{0,2}R\d+/i.test(line.trimStart())) continue
2123
+ if (topLevelJoin(line)) {
2124
+ sink.push({
2125
+ severity: "MINOR",
2126
+ kind: "other",
2127
+ evidence: `${file.path}:${index + 1}`,
2128
+ detail: "requirement joins multiple behaviors",
2129
+ fix: "Split into separate R items.",
2130
+ })
2131
+ }
2132
+ }
2133
+ }
2134
+
2135
+ /** BOM out, CRLF/CR in; the trailing newline is not a content line. */
2136
+ function normalizedLines(text: string): { readonly lines: string[]; readonly body: string } {
2137
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n")
2138
+ const body = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized
2139
+ return { lines: body.split("\n"), body: normalized }
2140
+ }
2141
+
2142
+ /**
2143
+ * Lint one file or the target files under a directory.
2144
+ * `ok: true` means the check ran; `verdict: "fail"` is a lint verdict, not an execution error.
2145
+ */
2146
+ export async function lintContext(options: ContextLintOptions): Promise<ContextLintOutcome> {
2147
+ if (typeof options !== "object" || options === null || typeof options.root !== "string") {
2148
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_INPUT", message: "root must be a string" }
2149
+ }
2150
+ if (options.target !== undefined && (typeof options.target !== "string" || options.target.trim() === "")) {
2151
+ // The tool rejects `path: ""` as E_INPUT; the library keeps the same contract.
2152
+ return {
2153
+ ok: false,
2154
+ schema: "context-lint-failure/v1",
2155
+ code: "E_INPUT",
2156
+ message: "target must be a non-empty string",
2157
+ }
2158
+ }
2159
+ const maxLines = options.maxLines ?? 200
2160
+ const maxTokens = options.maxTokens ?? 2500
2161
+ const maxWords = options.maxWords ?? 1500
2162
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES
2163
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES
2164
+ const maxFindingsPerFile = options.maxFindingsPerFile ?? DEFAULT_MAX_FINDINGS_PER_FILE
2165
+ const maxTotalFindings = options.maxTotalFindings ?? DEFAULT_MAX_TOTAL_FINDINGS
2166
+ for (const [name, value] of [
2167
+ ["maxLines", maxLines],
2168
+ ["maxTokens", maxTokens],
2169
+ ["maxWords", maxWords],
2170
+ ["maxFileBytes", maxFileBytes],
2171
+ ["maxFiles", maxFiles],
2172
+ ["maxFindingsPerFile", maxFindingsPerFile],
2173
+ ["maxTotalFindings", maxTotalFindings],
2174
+ ] as const) {
2175
+ if (!Number.isFinite(value) || value <= 0) {
2176
+ return {
2177
+ ok: false,
2178
+ schema: "context-lint-failure/v1",
2179
+ code: "E_INPUT",
2180
+ message: `${name} must be a positive number`,
2181
+ }
2182
+ }
2183
+ }
2184
+ if (options.signal?.aborted)
2185
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
2186
+
2187
+ const root = path.resolve(options.root)
2188
+ const realRoot = await canonical(root)
2189
+ const targets = await resolveTargets(root, options.target, maxFiles, realRoot, options.signal)
2190
+ if ("ok" in targets) return targets
2191
+ if (targets.files.length === 0) {
2192
+ return {
2193
+ ok: false,
2194
+ schema: "context-lint-failure/v1",
2195
+ code: "E_NO_FILES",
2196
+ message: "no AGENTS.md, SPEC.md or CLAUDE.md found in the target",
2197
+ }
2198
+ }
2199
+
2200
+ interface ScannedFile {
2201
+ readonly path: string
2202
+ readonly kind: ContextKind
2203
+ readonly lines: number
2204
+ readonly words: number
2205
+ readonly chars: number
2206
+ readonly tokens_est: number
2207
+ readonly findings: Omit<ContextFinding, "id">[]
2208
+ readonly truncated: boolean
2209
+ }
2210
+ const scanned: ScannedFile[] = []
2211
+ for (const absolute of targets.files) {
2212
+ if (options.signal?.aborted)
2213
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
2214
+ let size: number
2215
+ try {
2216
+ size = (await stat(absolute)).size
2217
+ } catch (error) {
2218
+ return {
2219
+ ok: false,
2220
+ schema: "context-lint-failure/v1",
2221
+ code: "E_IO",
2222
+ message: `cannot read ${absolute}: ${error instanceof Error ? error.message : String(error)}`,
2223
+ }
2224
+ }
2225
+ // Measure before reading: a multi-megabyte file would be parsed line by line
2226
+ // and echoed back through findings, which is the failure this cap prevents.
2227
+ // `stat` is a fast path with an exact size for the message; the read below
2228
+ // enforces the same cap on the bytes actually consumed, so a file replaced
2229
+ // between `stat` and the read cannot slip past it (TOCTOU).
2230
+ if (size > maxFileBytes) {
2231
+ return {
2232
+ ok: false,
2233
+ schema: "context-lint-failure/v1",
2234
+ code: "E_LIMIT_FILE",
2235
+ message: `${relative(root, absolute)} is ${size} bytes; limit ${maxFileBytes}`,
2236
+ path: relative(root, absolute),
2237
+ bytes: size,
2238
+ max: maxFileBytes,
2239
+ }
2240
+ }
2241
+ let text: string
2242
+ try {
2243
+ const read = await readCappedFile(absolute, maxFileBytes)
2244
+ if ("bytes" in read) {
2245
+ return {
2246
+ ok: false,
2247
+ schema: "context-lint-failure/v1",
2248
+ code: "E_LIMIT_FILE",
2249
+ message: `${relative(root, absolute)} grew past the ${maxFileBytes}-byte limit while it was read`,
2250
+ path: relative(root, absolute),
2251
+ bytes: read.bytes,
2252
+ max: maxFileBytes,
2253
+ }
2254
+ }
2255
+ text = read.text
2256
+ } catch (error) {
2257
+ return {
2258
+ ok: false,
2259
+ schema: "context-lint-failure/v1",
2260
+ code: "E_IO",
2261
+ message: `cannot read ${absolute}: ${error instanceof Error ? error.message : String(error)}`,
2262
+ }
2263
+ }
2264
+ // The read itself is not abortable here; a caller that aborted while a 2 MB
2265
+ // file was being read must still get E_ABORTED rather than a full report.
2266
+ if (options.signal?.aborted)
2267
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
2268
+ const { lines, body } = normalizedLines(text)
2269
+ // Requirements, AC ids and non-goals read the fence mask: inline code keeps
2270
+ // its text, so `AC1.1`, `R1` and `test_export` in backticks still parse.
2271
+ // Links, vague phrases, placeholders and the structural section checks read
2272
+ // the full mask: a command or a check that exists only as inline code is a
2273
+ // sample, not a rule.
2274
+ const fenceMask = maskFences(lines)
2275
+ const fullMask = maskAll(lines)
2276
+ const sectionMask = maskSectionBlocks(lines)
2277
+ const fenceMasked = fenceMask.masked
2278
+ const masked = fullMask.masked
2279
+ const sectionMasked = sectionMask.masked
2280
+ const chars = codePointLength(body)
2281
+ const words = body.trim() === "" ? 0 : body.trim().split(/\s+/).length
2282
+ const file: ContextFileResult = {
2283
+ path: relative(root, absolute),
2284
+ kind: kindOf(absolute),
2285
+ lines: lines.length,
2286
+ words,
2287
+ chars,
2288
+ tokens_est: estimateTokens(body),
2289
+ findings: [],
2290
+ }
2291
+ const sink = createSink(maxFindingsPerFile)
2292
+ checkBudget(file, sink, maxLines, maxTokens, maxWords)
2293
+ let linksTruncated = false
2294
+ try {
2295
+ await checkVague(file, masked, sink, options.signal)
2296
+ await checkDuplicates(file, masked, sink, options.signal)
2297
+ const links = await checkLinks(file, masked, root, realRoot, absolute, sink, options.signal)
2298
+ linksTruncated = links.truncated
2299
+ if (links.aborted || options.signal?.aborted) {
2300
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
2301
+ }
2302
+ await checkPlaceholders(file, lines, masked, sink, options.signal)
2303
+ checkFirstLines(file, lines, sink)
2304
+ checkMaskedRegions(file, fullMask, sink)
2305
+ await checkSections(file, lines, fenceMasked, sectionMasked, masked, sink, options.signal)
2306
+ await checkAcRows(file, fenceMasked, sink, options.signal)
2307
+ await checkAndChains(file, masked, sink, options.signal)
2308
+ } catch (error) {
2309
+ // A section check yields every `SECTION_ABORT_LINES` lines; an abort that
2310
+ // fired during that yield must win over a full verdict.
2311
+ if (error instanceof ScanAborted)
2312
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
2313
+ throw error
2314
+ }
2315
+ if (options.signal?.aborted)
2316
+ return { ok: false, schema: "context-lint-failure/v1", code: "E_ABORTED", message: "context lint aborted" }
2317
+ // Markers are appended directly after the checks: they never displace a kept
2318
+ // finding. The MAJOR marker is itself MAJOR, so it is closed with the rest.
2319
+ if (linksTruncated) {
2320
+ // The unverified remainder must not let the verdict pass: a capped link
2321
+ // list is incomplete, and a deterministic rerun is identical.
2322
+ sink.kept.push({
2323
+ severity: "MAJOR",
2324
+ kind: "other",
2325
+ evidence: `${file.path}:1`,
2326
+ detail: `link candidates truncated at ${MAX_LINK_CANDIDATES}; result incomplete (further links are not checked)`,
2327
+ fix: "Split the file or remove generated links so every target is checked.",
2328
+ })
2329
+ }
2330
+ if (sink.majorDropped) {
2331
+ sink.kept.push({
2332
+ severity: "MAJOR",
2333
+ kind: "other",
2334
+ evidence: `${file.path}:1`,
2335
+ detail: `findings truncated at ${maxFindingsPerFile} MAJOR (further MAJOR findings are not listed)`,
2336
+ fix: "Fix the listed findings or increase max_findings/max_total, then rerun; a deterministic rerun with the same caps shows the same prefix.",
2337
+ })
2338
+ }
2339
+ if (sink.minorDropped) {
2340
+ sink.kept.push({
2341
+ severity: "INFO",
2342
+ kind: "other",
2343
+ evidence: `${file.path}:1`,
2344
+ detail: `findings truncated at ${maxFindingsPerFile} (MAJOR findings are kept)`,
2345
+ fix: "Fix the listed findings or increase max_findings/max_total, then rerun; a deterministic rerun with the same caps shows the same prefix.",
2346
+ })
2347
+ }
2348
+ scanned.push({
2349
+ ...file,
2350
+ findings: sink.kept,
2351
+ truncated: sink.truncated || linksTruncated,
2352
+ })
2353
+ }
2354
+
2355
+ // The global cap applies to every finding and prioritizes MAJOR: a pathological
2356
+ // scan (500 files × 101 MAJOR) stays bounded, and a dropped MAJOR is replaced
2357
+ // by one MAJOR marker that is itself in `closing_list`, never hidden.
2358
+ const flat: { readonly fileIndex: number; readonly order: number; readonly finding: Omit<ContextFinding, "id"> }[] =
2359
+ []
2360
+ for (const [fileIndex, file] of scanned.entries()) {
2361
+ for (const [order, finding] of file.findings.entries()) flat.push({ fileIndex, order, finding })
2362
+ }
2363
+ const truncatedFiles = new Set<number>(scanned.flatMap((file, index) => (file.truncated ? [index] : [])))
2364
+ let selected = flat
2365
+ let globalTruncated = false
2366
+ if (flat.length > maxTotalFindings) {
2367
+ globalTruncated = true
2368
+ const byPosition = (
2369
+ left: { readonly fileIndex: number; readonly order: number },
2370
+ right: { readonly fileIndex: number; readonly order: number },
2371
+ ): number => left.fileIndex - right.fileIndex || left.order - right.order
2372
+ const majors = flat.filter((item) => item.finding.severity === "MAJOR").sort(byPosition)
2373
+ const rest = flat.filter((item) => item.finding.severity !== "MAJOR").sort(byPosition)
2374
+ const majorOverflow = majors.length > maxTotalFindings
2375
+ selected = majorOverflow
2376
+ ? majors.slice(0, maxTotalFindings)
2377
+ : [...majors, ...rest.slice(0, maxTotalFindings - majors.length)].sort(byPosition)
2378
+ const markerFile = selected.length > 0 ? selected[selected.length - 1].fileIndex : 0
2379
+ const kept = new Set(selected.map((item) => `${item.fileIndex}:${item.order}`))
2380
+ for (const item of flat) if (!kept.has(`${item.fileIndex}:${item.order}`)) truncatedFiles.add(item.fileIndex)
2381
+ selected = [
2382
+ ...selected,
2383
+ {
2384
+ fileIndex: markerFile,
2385
+ order: Number.MAX_SAFE_INTEGER,
2386
+ finding: {
2387
+ severity: majorOverflow ? "MAJOR" : "INFO",
2388
+ kind: "other",
2389
+ evidence: `${scanned[markerFile]?.path ?? ""}:1`,
2390
+ detail: majorOverflow
2391
+ ? `findings truncated at ${maxTotalFindings}; further MAJOR findings are not listed`
2392
+ : `findings truncated at ${maxTotalFindings} (MAJOR findings are kept)`,
2393
+ fix: "Fix the listed findings or increase max_findings/max_total, then rerun; a deterministic rerun with the same caps shows the same prefix.",
2394
+ },
2395
+ },
2396
+ ]
2397
+ truncatedFiles.add(markerFile)
2398
+ }
2399
+
2400
+ let counter = 0
2401
+ const files: ContextFileResult[] = []
2402
+ for (const [fileIndex, file] of scanned.entries()) {
2403
+ const findings: ContextFinding[] = []
2404
+ for (const item of selected) {
2405
+ if (item.fileIndex !== fileIndex) continue
2406
+ counter += 1
2407
+ findings.push({ id: `CTX-${String(counter).padStart(3, "0")}`, ...item.finding })
2408
+ }
2409
+ files.push({
2410
+ path: file.path,
2411
+ kind: file.kind,
2412
+ lines: file.lines,
2413
+ words: file.words,
2414
+ chars: file.chars,
2415
+ tokens_est: file.tokens_est,
2416
+ findings,
2417
+ ...(truncatedFiles.has(fileIndex) ? { truncated: true } : {}),
2418
+ })
2419
+ }
2420
+
2421
+ // A directory below the depth limit, or an entry whose junction/symlink
2422
+ // resolves outside the root, is not scanned; both are reported as INFO findings
2423
+ // (bounded), because files dropped by the walk must not read as "checked".
2424
+ const first = files[0]
2425
+ if (first !== undefined && (targets.skippedDepth > 0 || targets.skippedLinks.length > 0)) {
2426
+ const extra: ContextFinding[] = []
2427
+ const nextId = (): string => {
2428
+ counter += 1
2429
+ return `CTX-${String(counter).padStart(3, "0")}`
2430
+ }
2431
+ if (targets.skippedDepth > 0) {
2432
+ extra.push({
2433
+ id: nextId(),
2434
+ severity: "INFO",
2435
+ kind: "other",
2436
+ evidence: `${first.path}:1`,
2437
+ detail:
2438
+ targets.skippedDepth === 1
2439
+ ? "depth limit 3: 1 directory below the limit was not scanned"
2440
+ : `depth limit 3: ${targets.skippedDepth} directories below the limit were not scanned`,
2441
+ fix: "Pass an explicit path inside the deep directory to check it.",
2442
+ })
2443
+ }
2444
+ const reported = targets.skippedLinks.slice(0, MAX_SKIPPED_LINK_FINDINGS)
2445
+ for (const link of reported) {
2446
+ extra.push({
2447
+ id: nextId(),
2448
+ severity: "INFO",
2449
+ kind: "other",
2450
+ evidence: `${first.path}:1`,
2451
+ detail: clip(`link resolves outside the workspace root and was skipped: "${relative(root, link)}"`),
2452
+ fix: "Keep the link inside the workspace or remove it.",
2453
+ })
2454
+ }
2455
+ if (targets.skippedLinks.length > reported.length) {
2456
+ extra.push({
2457
+ id: nextId(),
2458
+ severity: "INFO",
2459
+ kind: "other",
2460
+ evidence: `${first.path}:1`,
2461
+ detail: `${targets.skippedLinks.length - reported.length} more links resolve outside the workspace root and were skipped`,
2462
+ fix: "Keep the links inside the workspace or remove them.",
2463
+ })
2464
+ }
2465
+ files[0] = { ...first, findings: [...first.findings, ...extra] }
2466
+ }
2467
+
2468
+ const all = files.flatMap((file) => file.findings)
2469
+ const major = all.filter((finding) => finding.severity === "MAJOR")
2470
+ const minor = all.filter((finding) => finding.severity === "MINOR").length
2471
+ const info = all.filter((finding) => finding.severity === "INFO").length
2472
+ const truncated = globalTruncated || truncatedFiles.size > 0
2473
+ return {
2474
+ schema: "context-lint/v1",
2475
+ ok: true,
2476
+ verdict: major.length > 0 ? "fail" : "pass",
2477
+ closing_list: major.map((finding) => finding.id),
2478
+ files,
2479
+ summary: `${files.length} file(s); MAJOR ${major.length}, MINOR ${minor}, INFO ${info}`,
2480
+ ...(truncated ? { truncated: true } : {}),
2481
+ }
2482
+ }