opencode-orchestrator-uni 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +239 -10
- package/README.ru.md +240 -10
- package/agents/en/context-lint.md +90 -0
- package/agents/en/orchestrator.md +10 -0
- package/agents/ru/context-lint.md +90 -0
- package/agents/ru/orchestrator.md +10 -0
- package/docs/en/context-efficiency.md +196 -0
- package/docs/en/root-runbook.md +2 -0
- package/docs/en/session-discipline.md +230 -0
- package/docs/ru/context-efficiency.md +196 -0
- package/docs/ru/root-runbook.md +2 -0
- package/docs/ru/session-discipline.md +225 -0
- package/index.ts +45 -7
- package/lib/commands.ts +204 -0
- package/lib/context-lint.ts +2482 -0
- package/lib/report-schema.ts +36 -0
- package/lib/session-budget.ts +1236 -0
- package/lib/tools.ts +276 -9
- package/package.json +1 -1
- package/schemas/context-lint-failure.v1.json +29 -0
- package/schemas/context-lint.v1.json +58 -0
|
@@ -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
|
+
}
|