pi-gauntlet 5.0.8 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,829 @@
1
+ // Pure plan-checker library. NO pi runtime import: this module is imported
2
+ // by plan-check.test.ts (node --test), which runs outside pi, where
3
+ // @earendil-works/pi-coding-agent is unresolvable. extensions/phase-tracker.ts
4
+ // owns the pi-facing tool registration and the real FsPort.
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ export interface PlanCheckFinding {
9
+ check: string;
10
+ line: number;
11
+ text: string;
12
+ reason: string;
13
+ }
14
+
15
+ export interface FsPort {
16
+ exists(path: string): boolean;
17
+ glob(pattern: string): string[];
18
+ }
19
+
20
+ export function sha256(bytes: Uint8Array): string {
21
+ return createHash("sha256").update(bytes).digest("hex");
22
+ }
23
+
24
+ const BANNED_TOKENS = ["TODO", "TBD", "xxx", "[fill in]", "<example>", "etc.", "probably", "something like"];
25
+
26
+ interface Anchor {
27
+ heading: string;
28
+ start: number;
29
+ end: number;
30
+ }
31
+
32
+ interface FileEntry {
33
+ kind: "create" | "modify" | "test";
34
+ line: number;
35
+ text: string;
36
+ path: string;
37
+ }
38
+
39
+ interface Task {
40
+ number: number;
41
+ line: number;
42
+ text: string;
43
+ bodyStartLine: number;
44
+ bodyEndLine: number;
45
+ waveNumber: number;
46
+ filesBlockMissing: boolean;
47
+ files: FileEntry[];
48
+ specAnchorLine: number | undefined;
49
+ anchors: Anchor[];
50
+ anchorParseError: boolean;
51
+ }
52
+
53
+ interface Wave {
54
+ number: number;
55
+ line: number;
56
+ text: string;
57
+ label: string;
58
+ soloLine: number | undefined;
59
+ soloReason: string | undefined;
60
+ }
61
+
62
+ interface CoverageRow {
63
+ line: number;
64
+ text: string;
65
+ anchorCell: string;
66
+ requirementCell: string;
67
+ ownerCell: string;
68
+ isMechanical: boolean;
69
+ isWaived: boolean;
70
+ ownerTasks: number[];
71
+ ownerMalformed: boolean;
72
+ anchor: Anchor | undefined;
73
+ }
74
+
75
+ interface Header {
76
+ specPathLine: number | undefined;
77
+ specPath: string | undefined;
78
+ verificationLine: number | undefined;
79
+ verificationText: string | undefined;
80
+ separatorLine: number | undefined;
81
+ }
82
+
83
+ interface ParsedPlan {
84
+ lines: string[];
85
+ header: Header;
86
+ waves: Wave[];
87
+ tasks: Task[];
88
+ coverageTableFound: boolean;
89
+ coverageRows: CoverageRow[];
90
+ }
91
+
92
+ interface SpecHeading {
93
+ line: number;
94
+ level: number;
95
+ title: string;
96
+ }
97
+
98
+ function fenceMask(lines: string[]): boolean[] {
99
+ const mask: boolean[] = [];
100
+ let inFence = false;
101
+ for (const line of lines) {
102
+ if (/^\s*```/.test(line)) {
103
+ mask.push(inFence);
104
+ inFence = !inFence;
105
+ } else {
106
+ mask.push(inFence);
107
+ }
108
+ }
109
+ return mask;
110
+ }
111
+
112
+ const ANCHOR_RE = /\u00a7\s*"([^"]+)"\s*L(\d+)(?:-L(\d+))?/g;
113
+
114
+ function parseAnchors(text: string): Anchor[] {
115
+ const anchors: Anchor[] = [];
116
+ const re = new RegExp(ANCHOR_RE.source, "g");
117
+ let m: RegExpExecArray | null;
118
+ while ((m = re.exec(text))) {
119
+ anchors.push({ heading: m[1], start: Number(m[2]), end: m[3] !== undefined ? Number(m[3]) : Number(m[2]) });
120
+ }
121
+ return anchors;
122
+ }
123
+
124
+ function parsePlan(planText: string): ParsedPlan {
125
+ const lines = planText.split("\n");
126
+ const mask = fenceMask(lines);
127
+
128
+ let separatorLine: number | undefined;
129
+ for (let i = 0; i < lines.length; i++) {
130
+ if (!mask[i] && lines[i].trim() === "---") {
131
+ separatorLine = i + 1;
132
+ break;
133
+ }
134
+ }
135
+
136
+ const headerRangeEnd = separatorLine ?? lines.length;
137
+ let specPathLine: number | undefined;
138
+ let specPath: string | undefined;
139
+ let verificationLine: number | undefined;
140
+ let verificationText: string | undefined;
141
+ for (let i = 0; i < headerRangeEnd; i++) {
142
+ if (mask[i]) continue;
143
+ const line = lines[i];
144
+ if (specPathLine === undefined && /^\*\*Spec:\*\*/.test(line) && !line.includes("\u00a7")) {
145
+ specPathLine = i + 1;
146
+ specPath = line
147
+ .replace(/^\*\*Spec:\*\*/, "")
148
+ .trim()
149
+ .replace(/^`|`$/g, "");
150
+ }
151
+ if (verificationLine === undefined && /^\*\*Verification:\*\*/.test(line)) {
152
+ verificationLine = i + 1;
153
+ verificationText = line.replace(/^\*\*Verification:\*\*/, "").trim();
154
+ }
155
+ }
156
+
157
+ const waveRe = /^## Wave (\d+) (\u2014|-) (.+)$/;
158
+ const taskRe = /^### Task (\d+): (.*)$/;
159
+ const headingBoundaryRe = /^#{2,3}\s/;
160
+
161
+ const waves: Wave[] = [];
162
+ for (let i = 0; i < lines.length; i++) {
163
+ if (mask[i]) continue;
164
+ const m = waveRe.exec(lines[i]);
165
+ if (!m) continue;
166
+ let soloLine: number | undefined;
167
+ let soloReason: string | undefined;
168
+ let j = i + 1;
169
+ while (j < lines.length && lines[j].trim() === "") j++;
170
+ if (j < lines.length) {
171
+ const sm = /^Solo: (.+)$/.exec(lines[j]);
172
+ if (sm) {
173
+ soloLine = j + 1;
174
+ soloReason = sm[1];
175
+ }
176
+ }
177
+ waves.push({ number: Number(m[1]), line: i + 1, text: lines[i], label: m[3], soloLine, soloReason });
178
+ }
179
+
180
+ const taskHeaderIdxs: number[] = [];
181
+ for (let i = 0; i < lines.length; i++) {
182
+ if (mask[i]) continue;
183
+ if (taskRe.test(lines[i])) taskHeaderIdxs.push(i);
184
+ }
185
+
186
+ const tasks: Task[] = [];
187
+ for (const i of taskHeaderIdxs) {
188
+ const m = taskRe.exec(lines[i])!;
189
+ let bodyEndIdx = lines.length - 1;
190
+ for (let k = i + 1; k < lines.length; k++) {
191
+ if (mask[k]) continue;
192
+ if (headingBoundaryRe.test(lines[k])) {
193
+ bodyEndIdx = k - 1;
194
+ break;
195
+ }
196
+ }
197
+ let waveNumber = -1;
198
+ for (const w of waves) {
199
+ if (w.line - 1 <= i) waveNumber = w.number;
200
+ }
201
+
202
+ let filesLine: number | undefined;
203
+ const files: FileEntry[] = [];
204
+ let specAnchorLine: number | undefined;
205
+ let anchors: Anchor[] = [];
206
+ let anchorParseError = false;
207
+
208
+ for (let k = i; k <= bodyEndIdx; k++) {
209
+ const line = lines[k];
210
+ if (filesLine === undefined && /^\*\*Files:\*\*/.test(line)) {
211
+ filesLine = k + 1;
212
+ let p = k + 1;
213
+ while (p <= bodyEndIdx) {
214
+ const l = lines[p];
215
+ if (l.trim() === "") {
216
+ p++;
217
+ continue;
218
+ }
219
+ const fm = /^- (Create|Modify|Test): (.+)$/.exec(l);
220
+ if (!fm) break;
221
+ files.push({
222
+ kind: fm[1].toLowerCase() as FileEntry["kind"],
223
+ line: p + 1,
224
+ text: l,
225
+ path: fm[2].trim().replace(/^`|`$/g, ""),
226
+ });
227
+ p++;
228
+ }
229
+ }
230
+ if (specAnchorLine === undefined && /^\*\*Spec:\*\*/.test(line) && line.includes("\u00a7")) {
231
+ specAnchorLine = k + 1;
232
+ anchors = parseAnchors(line);
233
+ if (anchors.length === 0) anchorParseError = true;
234
+ }
235
+ }
236
+
237
+ tasks.push({
238
+ number: Number(m[1]),
239
+ line: i + 1,
240
+ text: lines[i],
241
+ bodyStartLine: i + 1,
242
+ bodyEndLine: bodyEndIdx + 1,
243
+ waveNumber,
244
+ filesBlockMissing: filesLine === undefined,
245
+ files,
246
+ specAnchorLine,
247
+ anchors,
248
+ anchorParseError,
249
+ });
250
+ }
251
+
252
+ let coverageTableFound = false;
253
+ const coverageRows: CoverageRow[] = [];
254
+ for (let i = 0; i < lines.length; i++) {
255
+ if (mask[i]) continue;
256
+ if (!/^##\s+Spec coverage\s*$/i.test(lines[i])) continue;
257
+ coverageTableFound = true;
258
+ let p = i + 1;
259
+ while (p < lines.length && lines[p].trim() === "") p++;
260
+ if (p < lines.length && lines[p].trim().startsWith("|")) p++; // header row
261
+ if (p < lines.length && lines[p].trim().startsWith("|")) p++; // separator row
262
+ while (p < lines.length && lines[p].trim().startsWith("|")) {
263
+ const raw = lines[p];
264
+ const inner = raw.trim().replace(/^\|/, "").replace(/\|$/, "");
265
+ const cells = inner.split("|").map((c) => c.trim());
266
+ const [anchorCell = "", requirementCell = "", ownerCell = ""] = cells;
267
+ const isMechanical = /^mechanical:\s*/i.test(requirementCell);
268
+ const waivedMatch = /^waived:\s*(.*)$/i.exec(ownerCell);
269
+ const isTaskList = /^Task \d+(?:\s*,\s*Task \d+)*$/.test(ownerCell);
270
+ let isWaived = false;
271
+ let ownerTasks: number[] = [];
272
+ let ownerMalformed = false;
273
+ if (waivedMatch) {
274
+ if (waivedMatch[1].trim().length === 0) {
275
+ ownerMalformed = true;
276
+ } else {
277
+ isWaived = true;
278
+ }
279
+ } else if (isTaskList) {
280
+ ownerTasks = [...ownerCell.matchAll(/Task (\d+)/g)].map((mm) => Number(mm[1]));
281
+ } else {
282
+ ownerMalformed = true;
283
+ }
284
+ let anchor: Anchor | undefined;
285
+ if (anchorCell !== "-") {
286
+ const am = new RegExp(ANCHOR_RE.source).exec(anchorCell);
287
+ if (am) anchor = { heading: am[1], start: Number(am[2]), end: am[3] !== undefined ? Number(am[3]) : Number(am[2]) };
288
+ }
289
+ coverageRows.push({
290
+ line: p + 1,
291
+ text: raw,
292
+ anchorCell,
293
+ requirementCell,
294
+ ownerCell,
295
+ isMechanical,
296
+ isWaived,
297
+ ownerTasks,
298
+ ownerMalformed,
299
+ anchor,
300
+ });
301
+ p++;
302
+ }
303
+ break;
304
+ }
305
+
306
+ return {
307
+ lines,
308
+ header: { specPathLine, specPath, verificationLine, verificationText, separatorLine },
309
+ waves,
310
+ tasks,
311
+ coverageTableFound,
312
+ coverageRows,
313
+ };
314
+ }
315
+
316
+ function specHeadings(specLines: string[]): SpecHeading[] {
317
+ const mask = fenceMask(specLines);
318
+ const out: SpecHeading[] = [];
319
+ specLines.forEach((line, idx) => {
320
+ if (mask[idx]) return;
321
+ const m = /^(#{1,6})\s+(.*)$/.exec(line);
322
+ if (m) out.push({ line: idx + 1, level: m[1].length, title: m[2].trim() });
323
+ });
324
+ return out;
325
+ }
326
+
327
+ function sectionEnd(headings: SpecHeading[], idx: number, totalLines: number): number {
328
+ const h = headings[idx];
329
+ for (let j = idx + 1; j < headings.length; j++) {
330
+ if (headings[j].level <= h.level) return headings[j].line - 1;
331
+ }
332
+ return totalLines;
333
+ }
334
+
335
+ function stripLineSuffix(p: string): string {
336
+ return p.replace(/:\d+(-\d+)?$/, "");
337
+ }
338
+
339
+ function isGlob(p: string): boolean {
340
+ return /[*?{[\]]/.test(p);
341
+ }
342
+
343
+ function taskBodyText(task: Task, lines: string[]): string {
344
+ return lines.slice(task.bodyStartLine - 1, task.bodyEndLine).join("\n");
345
+ }
346
+
347
+ function extractLiterals(text: string): string[] {
348
+ const out: string[] = [];
349
+ const re = /`([^`]+)`/g;
350
+ let m: RegExpExecArray | null;
351
+ while ((m = re.exec(text))) {
352
+ const inner = m[1];
353
+ if (/<[^>]*>/.test(inner)) continue;
354
+ out.push(inner);
355
+ }
356
+ return out;
357
+ }
358
+
359
+ function requiredLiteralsForRow(row: CoverageRow, specLines: string[]): string[] {
360
+ if (!row.anchor) return [];
361
+ const { start, end } = row.anchor;
362
+ if (start < 1 || end > specLines.length || start > end) return [];
363
+ const text = specLines.slice(start - 1, end).join("\n");
364
+ return extractLiterals(text);
365
+ }
366
+
367
+ function computeRequiredLiteralsPerTask(parsed: ParsedPlan, specLines: string[]): Map<number, string[]> {
368
+ const map = new Map<number, string[]>();
369
+ if (!parsed.coverageTableFound) return map;
370
+ for (const row of parsed.coverageRows) {
371
+ if (row.ownerMalformed || row.isWaived || row.isMechanical) continue;
372
+ const literals = requiredLiteralsForRow(row, specLines);
373
+ if (literals.length === 0) continue;
374
+ for (const n of row.ownerTasks) {
375
+ const arr = map.get(n) ?? [];
376
+ arr.push(...literals);
377
+ map.set(n, arr);
378
+ }
379
+ }
380
+ return map;
381
+ }
382
+
383
+ function checkTableClosure(parsed: ParsedPlan): PlanCheckFinding[] {
384
+ const findings: PlanCheckFinding[] = [];
385
+ if (!parsed.coverageTableFound) {
386
+ findings.push({ check: "table-closure", line: 0, text: "", reason: "no '## Spec coverage' table found" });
387
+ return findings;
388
+ }
389
+ const taskByNumber = new Map(parsed.tasks.map((t) => [t.number, t]));
390
+ const coveredTasks = new Set<number>();
391
+ const mechanicalCoveredTasks = new Set<number>();
392
+
393
+ for (const row of parsed.coverageRows) {
394
+ if (row.ownerMalformed) {
395
+ findings.push({
396
+ check: "table-closure",
397
+ line: row.line,
398
+ text: row.text,
399
+ reason: "owner cell is not a 'Task <n>' list or 'waived: <reason>'",
400
+ });
401
+ continue;
402
+ }
403
+ if (row.isWaived) continue;
404
+ for (const n of row.ownerTasks) {
405
+ coveredTasks.add(n);
406
+ if (row.isMechanical) mechanicalCoveredTasks.add(n);
407
+ const task = taskByNumber.get(n);
408
+ if (!task) {
409
+ findings.push({
410
+ check: "table-closure",
411
+ line: row.line,
412
+ text: row.text,
413
+ reason: `row references Task ${n} but no such task exists`,
414
+ });
415
+ continue;
416
+ }
417
+ if (row.isMechanical) continue;
418
+ if (!row.anchor) {
419
+ findings.push({
420
+ check: "table-closure",
421
+ line: row.line,
422
+ text: row.text,
423
+ reason: 'requirement row anchor is not a parseable \u00a7 "heading" L<n>-L<n> anchor',
424
+ });
425
+ continue;
426
+ }
427
+ const contained = task.anchors.some(
428
+ (a) => a.heading === row.anchor!.heading && a.start === row.anchor!.start && a.end === row.anchor!.end,
429
+ );
430
+ if (!contained) {
431
+ findings.push({
432
+ check: "table-closure",
433
+ line: row.line,
434
+ text: row.text,
435
+ reason: `row anchor \u00a7 "${row.anchor.heading}" L${row.anchor.start}-L${row.anchor.end} is not in Task ${n}'s **Spec:** anchor set`,
436
+ });
437
+ }
438
+ }
439
+ }
440
+
441
+ for (const task of parsed.tasks) {
442
+ if (!coveredTasks.has(task.number)) {
443
+ findings.push({
444
+ check: "table-closure",
445
+ line: task.line,
446
+ text: task.text,
447
+ reason: `Task ${task.number} does not appear as an owner in any '## Spec coverage' row`,
448
+ });
449
+ continue;
450
+ }
451
+ const anchorless = task.anchors.length === 0;
452
+ if (anchorless && !mechanicalCoveredTasks.has(task.number)) {
453
+ findings.push({
454
+ check: "table-closure",
455
+ line: task.line,
456
+ text: task.text,
457
+ reason: `Task ${task.number} has no **Spec:** anchor and no mechanical coverage row`,
458
+ });
459
+ }
460
+ }
461
+ return findings;
462
+ }
463
+
464
+ function checkQuoteIntegrity(parsed: ParsedPlan, specLines: string[]): PlanCheckFinding[] {
465
+ const findings: PlanCheckFinding[] = [];
466
+ if (!parsed.coverageTableFound) return findings;
467
+ const taskByNumber = new Map(parsed.tasks.map((t) => [t.number, t]));
468
+ for (const row of parsed.coverageRows) {
469
+ if (row.ownerMalformed || row.isWaived || row.isMechanical) continue;
470
+ const literals = requiredLiteralsForRow(row, specLines);
471
+ if (literals.length === 0) continue;
472
+ for (const n of row.ownerTasks) {
473
+ const task = taskByNumber.get(n);
474
+ if (!task) continue;
475
+ const body = taskBodyText(task, parsed.lines);
476
+ for (const lit of literals) {
477
+ if (!body.includes(lit)) {
478
+ findings.push({
479
+ check: "quote-integrity",
480
+ line: row.line,
481
+ text: row.text,
482
+ reason: `Task ${n} body does not contain the required verbatim literal \`${lit}\` from its anchored spec lines`,
483
+ });
484
+ }
485
+ }
486
+ }
487
+ }
488
+ return findings;
489
+ }
490
+
491
+ function checkAnchorResolution(parsed: ParsedPlan, specLines: string[]): PlanCheckFinding[] {
492
+ const findings: PlanCheckFinding[] = [];
493
+ const headings = specHeadings(specLines);
494
+ for (const task of parsed.tasks) {
495
+ if (task.specAnchorLine === undefined) continue;
496
+ const anchorLineText = parsed.lines[task.specAnchorLine - 1];
497
+ if (task.anchorParseError) {
498
+ findings.push({
499
+ check: "anchor-resolution",
500
+ line: task.specAnchorLine,
501
+ text: anchorLineText,
502
+ reason: 'unparseable **Spec:** anchor line (expected \u00a7 "heading" L<n>-L<n>)',
503
+ });
504
+ continue;
505
+ }
506
+ for (const a of task.anchors) {
507
+ const matches = headings.filter((h) => h.title === a.heading);
508
+ if (matches.length === 0) {
509
+ findings.push({
510
+ check: "anchor-resolution",
511
+ line: task.specAnchorLine,
512
+ text: anchorLineText,
513
+ reason: `no spec heading matches "${a.heading}"`,
514
+ });
515
+ continue;
516
+ }
517
+ if (matches.length >= 2) {
518
+ findings.push({
519
+ check: "anchor-resolution",
520
+ line: task.specAnchorLine,
521
+ text: anchorLineText,
522
+ reason: `ambiguous spec heading "${a.heading}" matches ${matches.length} headings`,
523
+ });
524
+ continue;
525
+ }
526
+ const idx = headings.indexOf(matches[0]);
527
+ const end = sectionEnd(headings, idx, specLines.length);
528
+ const headingLine = matches[0].line;
529
+ if (!(a.start <= a.end && a.start >= 1 && a.end <= specLines.length)) {
530
+ findings.push({
531
+ check: "anchor-resolution",
532
+ line: task.specAnchorLine,
533
+ text: anchorLineText,
534
+ reason: `anchor range L${a.start}-L${a.end} is not in-bounds/non-empty`,
535
+ });
536
+ continue;
537
+ }
538
+ if (!(a.start >= headingLine && a.end <= end)) {
539
+ findings.push({
540
+ check: "anchor-resolution",
541
+ line: task.specAnchorLine,
542
+ text: anchorLineText,
543
+ reason: `anchor range L${a.start}-L${a.end} is outside heading "${a.heading}"'s section (L${headingLine}-L${end})`,
544
+ });
545
+ }
546
+ }
547
+ }
548
+ return findings;
549
+ }
550
+
551
+ function checkPathsExist(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
552
+ const findings: PlanCheckFinding[] = [];
553
+ for (const task of parsed.tasks) {
554
+ if (task.filesBlockMissing) {
555
+ findings.push({
556
+ check: "paths-exist",
557
+ line: task.line,
558
+ text: task.text,
559
+ reason: "task is missing a **Files:** block",
560
+ });
561
+ continue;
562
+ }
563
+ for (const f of task.files) {
564
+ if (f.kind !== "modify") continue;
565
+ const path = stripLineSuffix(f.path);
566
+ if (isGlob(path)) {
567
+ let matches: string[];
568
+ try {
569
+ matches = fs.glob(path);
570
+ } catch (err) {
571
+ findings.push({
572
+ check: "paths-exist",
573
+ line: f.line,
574
+ text: f.text,
575
+ reason: `Modify: glob "${path}" is invalid: ${String(err)}`,
576
+ });
577
+ continue;
578
+ }
579
+ if (matches.length === 0) {
580
+ findings.push({
581
+ check: "paths-exist",
582
+ line: f.line,
583
+ text: f.text,
584
+ reason: `Modify: glob "${path}" matched no files`,
585
+ });
586
+ }
587
+ } else if (!fs.exists(path)) {
588
+ findings.push({
589
+ check: "paths-exist",
590
+ line: f.line,
591
+ text: f.text,
592
+ reason: `Modify: path "${path}" does not exist`,
593
+ });
594
+ }
595
+ }
596
+ }
597
+ return findings;
598
+ }
599
+
600
+ function checkPlaceholderScan(parsed: ParsedPlan, requiredLiterals: Map<number, string[]>): PlanCheckFinding[] {
601
+ const findings: PlanCheckFinding[] = [];
602
+ const lineTask = new Map<number, Task>();
603
+ for (const t of parsed.tasks) {
604
+ for (let ln = t.bodyStartLine; ln <= t.bodyEndLine; ln++) lineTask.set(ln, t);
605
+ }
606
+ parsed.lines.forEach((line, idx) => {
607
+ const lineNo = idx + 1;
608
+ const lower = line.toLowerCase();
609
+ const task = lineTask.get(lineNo);
610
+ const literals = task ? (requiredLiterals.get(task.number) ?? []) : [];
611
+ const exemptSpans: [number, number][] = [];
612
+ for (const lit of literals) {
613
+ let from = 0;
614
+ for (;;) {
615
+ const at = line.indexOf(lit, from);
616
+ if (at === -1) break;
617
+ exemptSpans.push([at, at + lit.length]);
618
+ from = at + lit.length;
619
+ }
620
+ }
621
+ for (const token of BANNED_TOKENS) {
622
+ const tokenLower = token.toLowerCase();
623
+ let from = 0;
624
+ for (;;) {
625
+ const at = lower.indexOf(tokenLower, from);
626
+ if (at === -1) break;
627
+ const end = at + token.length;
628
+ const exempt = exemptSpans.some(([s, e]) => at >= s && end <= e);
629
+ if (!exempt) {
630
+ findings.push({
631
+ check: "placeholder-scan",
632
+ line: lineNo,
633
+ text: line,
634
+ reason: `banned placeholder token "${token}" found`,
635
+ });
636
+ }
637
+ from = at + token.length;
638
+ }
639
+ }
640
+ });
641
+ return findings;
642
+ }
643
+
644
+ function fileEntries(task: Task): { path: string; kind: "literal" | "glob" }[] {
645
+ return task.files.map((f) => {
646
+ const p = stripLineSuffix(f.path);
647
+ return { path: p, kind: (isGlob(p) ? "glob" : "literal") as "literal" | "glob" };
648
+ });
649
+ }
650
+
651
+ function checkWaveFileDisjointness(parsed: ParsedPlan, fs: FsPort): PlanCheckFinding[] {
652
+ const findings: PlanCheckFinding[] = [];
653
+ const globCache = new Map<string, string[] | Error>();
654
+ const expand = (pattern: string): string[] | Error => {
655
+ const cached = globCache.get(pattern);
656
+ if (cached !== undefined) return cached;
657
+ let result: string[] | Error;
658
+ try {
659
+ result = fs.glob(pattern);
660
+ } catch (err) {
661
+ result = err instanceof Error ? err : new Error(String(err));
662
+ }
663
+ globCache.set(pattern, result);
664
+ return result;
665
+ };
666
+
667
+ for (const wave of parsed.waves) {
668
+ const tasks = parsed.tasks.filter((t) => t.waveNumber === wave.number);
669
+ if (tasks.length < 2) continue;
670
+ for (let i = 0; i < tasks.length; i++) {
671
+ for (let j = i + 1; j < tasks.length; j++) {
672
+ const a = fileEntries(tasks[i]);
673
+ const b = fileEntries(tasks[j]);
674
+ for (const ea of a) {
675
+ for (const eb of b) {
676
+ let overlap = false;
677
+ let errFinding: PlanCheckFinding | undefined;
678
+ if (ea.kind === "literal" && eb.kind === "literal") {
679
+ overlap = ea.path === eb.path;
680
+ } else if (ea.kind === "glob" && eb.kind === "literal") {
681
+ const exp = expand(ea.path);
682
+ if (exp instanceof Error) errFinding = globErrFinding(wave, ea.path, exp);
683
+ else overlap = exp.includes(eb.path);
684
+ } else if (ea.kind === "literal" && eb.kind === "glob") {
685
+ const exp = expand(eb.path);
686
+ if (exp instanceof Error) errFinding = globErrFinding(wave, eb.path, exp);
687
+ else overlap = exp.includes(ea.path);
688
+ } else {
689
+ const expA = expand(ea.path);
690
+ const expB = expand(eb.path);
691
+ if (expA instanceof Error) errFinding = globErrFinding(wave, ea.path, expA);
692
+ else if (expB instanceof Error) errFinding = globErrFinding(wave, eb.path, expB);
693
+ else overlap = expA.some((p) => expB.includes(p));
694
+ }
695
+ if (errFinding) {
696
+ findings.push(errFinding);
697
+ continue;
698
+ }
699
+ if (overlap) {
700
+ findings.push({
701
+ check: "wave-file-disjointness",
702
+ line: wave.line,
703
+ text: wave.text,
704
+ reason: `Task ${tasks[i].number} and Task ${tasks[j].number} in Wave ${wave.number} both declare "${ea.path}" / "${eb.path}"`,
705
+ });
706
+ }
707
+ }
708
+ }
709
+ }
710
+ }
711
+ }
712
+ return findings;
713
+ }
714
+
715
+ function globErrFinding(wave: Wave, pattern: string, err: Error): PlanCheckFinding {
716
+ return {
717
+ check: "wave-file-disjointness",
718
+ line: wave.line,
719
+ text: wave.text,
720
+ reason: `invalid glob pattern "${pattern}": ${err.message}`,
721
+ };
722
+ }
723
+
724
+ function checkSoloLine(parsed: ParsedPlan): PlanCheckFinding[] {
725
+ const findings: PlanCheckFinding[] = [];
726
+ for (const wave of parsed.waves) {
727
+ const count = parsed.tasks.filter((t) => t.waveNumber === wave.number).length;
728
+ if (count !== 1) continue;
729
+ if (wave.soloLine === undefined || !wave.soloReason || wave.soloReason.trim().length === 0) {
730
+ findings.push({
731
+ check: "solo-line",
732
+ line: wave.line,
733
+ text: wave.text,
734
+ reason: `single-task Wave ${wave.number} is missing a 'Solo: <reason>' line directly under its header`,
735
+ });
736
+ }
737
+ }
738
+ return findings;
739
+ }
740
+
741
+ function checkHeaderEntrypoint(parsed: ParsedPlan): PlanCheckFinding[] {
742
+ const findings: PlanCheckFinding[] = [];
743
+ if (parsed.header.verificationText === undefined || parsed.header.separatorLine === undefined) {
744
+ findings.push({
745
+ check: "header-entrypoint",
746
+ line: 0,
747
+ text: "",
748
+ reason: "missing header **Verification:** line or '---' separator",
749
+ });
750
+ return findings;
751
+ }
752
+ const entrypoint = parsed.header.verificationText.trim();
753
+ if (!entrypoint) return findings;
754
+
755
+ const waveBoundaryRe = /^##\s/;
756
+ const inScope = new Set<number>();
757
+ for (const wave of parsed.waves) {
758
+ const hIdx = wave.line - 1;
759
+ let endIdx = parsed.lines.length - 1;
760
+ for (let idx = hIdx + 1; idx < parsed.lines.length; idx++) {
761
+ if (waveBoundaryRe.test(parsed.lines[idx])) {
762
+ endIdx = idx - 1;
763
+ break;
764
+ }
765
+ }
766
+ for (let idx = hIdx; idx <= endIdx; idx++) inScope.add(idx + 1);
767
+ }
768
+
769
+ for (const ln of [...inScope].sort((a, b) => a - b)) {
770
+ const line = parsed.lines[ln - 1];
771
+ if (line.includes(entrypoint)) {
772
+ findings.push({
773
+ check: "header-entrypoint",
774
+ line: ln,
775
+ text: line,
776
+ reason: `header entrypoint "${entrypoint}" also appears outside the header (must be header-only)`,
777
+ });
778
+ }
779
+ }
780
+ return findings;
781
+ }
782
+
783
+ export function checkPlan(planText: string, specText: string, fs: FsPort): PlanCheckFinding[] {
784
+ try {
785
+ const parsed = parsePlan(planText);
786
+ const findings: PlanCheckFinding[] = [];
787
+
788
+ if (parsed.waves.length === 0 && parsed.tasks.length === 0) {
789
+ if (parsed.waves.length === 0) {
790
+ findings.push({
791
+ check: "input",
792
+ line: 0,
793
+ text: "",
794
+ reason: "no wave headers found (expected '## Wave N \u2014 label' or '## Wave N - label')",
795
+ });
796
+ }
797
+ if (parsed.tasks.length === 0) {
798
+ findings.push({
799
+ check: "input",
800
+ line: 0,
801
+ text: "",
802
+ reason: "no task headers found (expected '### Task N: label')",
803
+ });
804
+ }
805
+ }
806
+
807
+ const specLines = specText.split("\n");
808
+ findings.push(...checkTableClosure(parsed));
809
+ findings.push(...checkQuoteIntegrity(parsed, specLines));
810
+ findings.push(...checkAnchorResolution(parsed, specLines));
811
+ findings.push(...checkPathsExist(parsed, fs));
812
+ const requiredLiterals = computeRequiredLiteralsPerTask(parsed, specLines);
813
+ findings.push(...checkPlaceholderScan(parsed, requiredLiterals));
814
+ findings.push(...checkWaveFileDisjointness(parsed, fs));
815
+ findings.push(...checkSoloLine(parsed));
816
+ findings.push(...checkHeaderEntrypoint(parsed));
817
+ return findings;
818
+ } catch (err) {
819
+ const message = String(err instanceof Error ? err.message : err);
820
+ return [
821
+ {
822
+ check: "internal",
823
+ line: 0,
824
+ text: "",
825
+ reason: message.split("\n")[0],
826
+ },
827
+ ];
828
+ }
829
+ }