@pareto4/pp-core 1.20.0 → 1.21.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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-nyquist-auditor.md +19 -14
  3. package/agents/pp-plan-checker.md +59 -36
  4. package/agents/pp-planner.md +34 -53
  5. package/agents/pp-verifier.md +3 -52
  6. package/commands/pp-autonomous.md +2 -1
  7. package/commands/pp-plan-phase.md +2 -1
  8. package/commands/pp-plan-review-convergence.md +2 -1
  9. package/gemini-extension.json +1 -1
  10. package/package.json +1 -1
  11. package/pp-core/bin/lib/capability-activation.cjs +77 -0
  12. package/pp-core/bin/lib/capability-registry.cjs +14 -14
  13. package/pp-core/bin/lib/capability-state.cjs +8 -18
  14. package/pp-core/bin/lib/ceremony.cjs +1342 -0
  15. package/pp-core/bin/lib/check-command-router.cjs +62 -1
  16. package/pp-core/bin/lib/commands.cjs +29 -3
  17. package/pp-core/bin/lib/config.cjs +5 -0
  18. package/pp-core/bin/lib/init.cjs +50 -1
  19. package/pp-core/bin/lib/io.cjs +2 -0
  20. package/pp-core/bin/lib/loop-resolver.cjs +26 -12
  21. package/pp-core/bin/lib/phase.cjs +39 -3
  22. package/pp-core/bin/lib/plan-sizing.cjs +346 -0
  23. package/pp-core/bin/lib/test-substance.cjs +3190 -0
  24. package/pp-core/bin/lib/timeline.cjs +605 -0
  25. package/pp-core/bin/pp-tools.cjs +71 -4
  26. package/pp-core/bin/shared/ceremony-gates.json +17 -0
  27. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  28. package/pp-core/references/ceremony.md +81 -0
  29. package/pp-core/references/few-shot-examples/plan-checker.md +8 -8
  30. package/pp-core/references/phase-timeline.md +107 -0
  31. package/pp-core/references/planner-antipatterns.md +32 -0
  32. package/pp-core/references/planner-guidance.md +17 -14
  33. package/pp-core/references/planner-revision.md +1 -1
  34. package/pp-core/references/planning-config.md +3 -2
  35. package/pp-core/references/test-substance.md +324 -0
  36. package/pp-core/references/verifier-stub-patterns.md +51 -0
  37. package/pp-core/templates/VALIDATION.md +1 -0
  38. package/pp-core/templates/phase-prompt.md +19 -7
  39. package/pp-core/templates/roadmap.md +1 -1
  40. package/pp-core/templates/verification-report.md +1 -1
  41. package/pp-core/workflows/audit-milestone.md +4 -1
  42. package/pp-core/workflows/autonomous.md +11 -8
  43. package/pp-core/workflows/code-review.md +15 -6
  44. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +7 -7
  45. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -2
  46. package/pp-core/workflows/execute-phase.md +14 -3
  47. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +7 -3
  48. package/pp-core/workflows/plan-phase/steps/prd-express.md +104 -0
  49. package/pp-core/workflows/plan-phase.md +47 -114
  50. package/pp-core/workflows/plan-review-convergence.md +6 -3
  51. package/pp-core/workflows/ship.md +2 -2
  52. package/pp-core/workflows/stats.md +19 -4
  53. package/pp-core/workflows/validate-phase.md +19 -4
  54. package/pp-core/workflows/verify-phase.md +1 -1
  55. package/pp-core/workflows/verify-work.md +1 -1
  56. package/scripts/gen-capability-registry.cjs +43 -45
  57. package/scripts/run-tests.cjs +54 -30
@@ -0,0 +1,3190 @@
1
+ "use strict";
2
+ /**
3
+ * Test Substance: plan-time analysis of whether a phase's tasks assert behaviour (Phase 38).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the gitignored `pp-core/bin/lib/test-substance.cjs`; do NOT
7
+ * hand-edit the compiled output.
8
+ *
9
+ * `check test-substance <phase>` reads every PLAN.md of a phase and reports, per
10
+ * task: a missing, empty or generic `<behavior>` (D-01, D-02); an `<automated>`
11
+ * command that can pass without asserting (D-06); a MISSING marker left as the
12
+ * verify, which the executor cannot run (D-03); a test file a real command runs that is
13
+ * not on disk and that no earlier task, earlier-wave plan or the task itself creates
14
+ * (`orderFinding`, D-03, D-06); and a scaffold whose action describes an empty,
15
+ * placeholder, todo, skip or tautological test (D-03). Every finding is a blocker
16
+ * (D-07). With `workflow.nyquist_validation: false` the verb reports `enforced:
17
+ * false`, keeps the findings listed and does not block (D-08). Tasks that touch a
18
+ * source file without `tdd="true"` are listed as information and never set `block`
19
+ * (D-12). It reads plans, and stats (never runs) the test files they run, so it runs
20
+ * with or without RESEARCH.md (D-05).
21
+ *
22
+ * The second half is the test-file scanner (`scanTestFiles`, D-14, D-16): it counts
23
+ * assertions per test case in JS/TS, Python and Go files and reports the cases that
24
+ * assert nothing, for validate-phase and the Nyquist auditor.
25
+ *
26
+ * The module is static text analysis. It never runs a plan's command or a test
27
+ * file, and it imports no process-spawning built-in (T-38-05). Every input is
28
+ * size-capped and every scan is linear: child elements are found with `indexOf`
29
+ * loops, never a lazy whole-task regex, and commands are split on separators with
30
+ * no whitespace quantifier around them (T-38-01).
31
+ */
32
+ var __importDefault = (this && this.__importDefault) || function (mod) {
33
+ return (mod && mod.__esModule) ? mod : { "default": mod };
34
+ };
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ASSERTION_VOCABULARY = exports.JS_NON_CASE_MEMBERS = exports.JS_CASE_PRODUCING_MEMBERS = exports.JS_CASE_MODIFIERS = exports.SCAN_UNKNOWN_REASONS = exports.FILE_STATES = exports.CASE_STATES = exports.MAX_LISTED_CASES = exports.MAX_SCAN_FILES = exports.MAX_SCAN_FILE_BYTES = exports.MAX_QUOTED_CHARS = exports.MAX_COMMAND_CHARS = exports.MAX_PLAN_BYTES = exports.MAX_PLAN_CHARS = exports.ORDER_SKIP_RUNNERS = exports.ORDER_SKIP_VERBS = exports.CWD_OPTIONS = exports.MAX_ORDER_PATHS = exports.TEST_PATH_PATTERNS = exports.TEST_FILE_PATTERNS = exports.SCAFFOLD_NEGATIONS = exports.SCAFFOLD_VACUOUS_PATTERNS = exports.TAUTOLOGY_LITERALS = exports.GENERIC_BEHAVIORS = exports.BEHAVIOR_MIN_WORDS = exports.BEHAVIOR_MIN_CHARS = exports.SCAFFOLD_VACUOUS_KINDS = exports.VACUOUS_COMMAND_KINDS = exports.FINDING_CODES = void 0;
37
+ exports.isTestPath = isTestPath;
38
+ exports.segmentCommand = segmentCommand;
39
+ exports.classifyCommand = classifyCommand;
40
+ exports.checkBehavior = checkBehavior;
41
+ exports.parsePlanTasks = parsePlanTasks;
42
+ exports.isMissingMarker = isMissingMarker;
43
+ exports.commandTestPaths = commandTestPaths;
44
+ exports.analyzeTestSubstance = analyzeTestSubstance;
45
+ exports.readPhaseSubstancePlans = readPhaseSubstancePlans;
46
+ exports.projectPathExists = projectPathExists;
47
+ exports.computeTestSubstance = computeTestSubstance;
48
+ exports.languageOf = languageOf;
49
+ exports.maskJs = maskJs;
50
+ exports.maskPython = maskPython;
51
+ exports.maskGo = maskGo;
52
+ exports.scanTestSource = scanTestSource;
53
+ exports.scanTestFile = scanTestFile;
54
+ exports.scanTestFiles = scanTestFiles;
55
+ const node_fs_1 = __importDefault(require("node:fs"));
56
+ const node_path_1 = __importDefault(require("node:path"));
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const coreUtilsMod = require("./core-utils.cjs");
59
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
60
+ const phaseScopeMod = require("./phase-scope.cjs");
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const taskCommandMod = require("./task-command-router.cjs");
63
+ const security_cjs_1 = require("./security.cjs");
64
+ const { extractCanonicalPlanId } = coreUtilsMod;
65
+ const { resolvePhaseForRead } = phaseScopeMod;
66
+ const { isBehaviorAddingTaskContent } = taskCommandMod;
67
+ // ─── Constants (cited by pp-core/references/test-substance.md) ─────────────────
68
+ exports.FINDING_CODES = ['behavior_missing', 'behavior_generic', 'verify_vacuous',
69
+ 'scaffold_uncreated', 'scaffold_empty', 'over_cap', 'unreadable'];
70
+ exports.VACUOUS_COMMAND_KINDS = ['existence_only', 'grep_test_file', 'or_true',
71
+ 'semicolon_true', 'exit_zero', 'pass_with_no_tests', 'echo_only', 'no_command'];
72
+ exports.SCAFFOLD_VACUOUS_KINDS = ['empty_body', 'placeholder', 'todo', 'skip', 'tautology'];
73
+ exports.BEHAVIOR_MIN_CHARS = 20;
74
+ exports.BEHAVIOR_MIN_WORDS = 4;
75
+ /** Whole-statement denylist, compared with the NORMALISED statement (never as a substring). */
76
+ exports.GENERIC_BEHAVIORS = ['tests pass', 'test passes', 'the tests pass', 'all tests pass',
77
+ 'all the tests pass', 'it works', 'works', 'works as expected', 'it works as expected',
78
+ 'everything works', 'file exists', 'the file exists', 'files exist', 'is implemented',
79
+ 'it is implemented', 'implemented', 'done', 'passes', 'complete', 'build passes',
80
+ 'the build passes'];
81
+ /** Case-sensitive substrings, matched after collapsing whitespace runs to one space. */
82
+ exports.TAUTOLOGY_LITERALS = ['assert.ok(true)', 'assert(true)', 'expect(true).toBe(true)',
83
+ 'expect(true).toBeTruthy()', 'assert.equal(1, 1)', 'assert.strictEqual(1, 1)', 'assert True',
84
+ 'self.assertTrue(True)', 'assert.True(t, true)'];
85
+ /**
86
+ * A writing verb and at most `words` more words before the vacuous phrase, so prose
87
+ * forms match "write a placeholder test" but not "counts skipped tests" or "rejects
88
+ * an empty test name" (WR-02). The phrase is group 1, so the negation check can read
89
+ * the words right before it. Matched per collapsed sentence; `[^ ]+` cannot cross a
90
+ * space, so each repetition is one word and the match is linear.
91
+ */
92
+ function scaffoldVerb(words) {
93
+ return `\\b(?:write|writes|create|creates|add|adds|leave|leaves|scaffold|scaffolds|stub|stubs|keep|keeps|put|insert|generate|mark|marks)(?: [^ ]+){0,${words}} `;
94
+ }
95
+ /** Four words for the phrases with a descriptive reading ("counts skipped tests"); "placeholder test" has none, so eight (WR-04). */
96
+ const VERB_WINDOW = scaffoldVerb(4);
97
+ const PLACEHOLDER_VERB_WINDOW = scaffoldVerb(8);
98
+ exports.SCAFFOLD_VACUOUS_PATTERNS = [
99
+ { kind: 'empty_body', pattern: new RegExp(`${VERB_WINDOW}(empty (?:test )?(?:body|bodies|callback))\\b`, 'i') },
100
+ { kind: 'empty_body', pattern: new RegExp(`${VERB_WINDOW}(empty tests?)\\b`, 'i') },
101
+ { kind: 'placeholder', pattern: new RegExp(`${PLACEHOLDER_VERB_WINDOW}(placeholder (?:tests?|assertions?|specs?|cases?))\\b`, 'i') },
102
+ { kind: 'todo', pattern: /\b(?:test|it|describe)\.todo\b/i },
103
+ { kind: 'todo', pattern: new RegExp(`${VERB_WINDOW}(todo (?:tests?|assertions?|cases?))\\b`, 'i') },
104
+ { kind: 'skip', pattern: /\b(?:test|it|describe)\.skip\b/i },
105
+ { kind: 'skip', pattern: new RegExp(`${VERB_WINDOW}(skipped (?:tests?|assertions?|cases?))\\b`, 'i') },
106
+ { kind: 'skip', pattern: /@pytest\.mark\.skip\b/ },
107
+ { kind: 'skip', pattern: /\bt\.Skip(?:Now|f)?\(/ },
108
+ ];
109
+ /**
110
+ * Words that state what NOT to write ("never a placeholder test"). `no-op` is not a
111
+ * negation. Listed in pp-core/references/test-substance.md.
112
+ */
113
+ exports.SCAFFOLD_NEGATIONS = ['never', 'not', 'no', "don't", 'dont', 'without', 'avoid', 'instead of'];
114
+ const SCAFFOLD_NEGATION = new RegExp(`\\b(?:${exports.SCAFFOLD_NEGATIONS.join('|')})\\b(?!-)`, 'i');
115
+ /**
116
+ * A negation reaches no further back than the start of its clause, which "then" or
117
+ * "but" opens. A comma does not: "never a placeholder test, test.skip or
118
+ * assert.ok(true)" negates the whole list.
119
+ */
120
+ const CLAUSE_BREAK = /\b(?:then|but)\b/i;
121
+ /**
122
+ * A verb that removes the placeholder it governs ("replaces the placeholder test",
123
+ * "in place of the placeholder test"): the remedy, not a scaffold (WR-02). It must
124
+ * stand right before the phrase, with only determiners between, so "replaces it
125
+ * with a placeholder test" still blocks.
126
+ */
127
+ const PLACEHOLDER_REMEDY = /\b(?:replace|replaces|replaced|replacing|remove|removes|removed|removing|delete|deletes|deleted|deleting|in place of)(?: (?:the|a|an|that|this|these|those|each|every|any|all|its|their|existing|old|legacy))*$/i;
128
+ /** The three test-file-name patterns; the order check reads a path only when one matches. */
129
+ exports.TEST_FILE_PATTERNS = [
130
+ /\.(?:test|spec)\.[A-Za-z0-9]+$/, /(?:^|\/)test_[^/]+\.py$/, /_test\.(?:go|py)$/,
131
+ ];
132
+ exports.TEST_PATH_PATTERNS = [...exports.TEST_FILE_PATTERNS, /(?:^|\/)(?:tests?|__tests__)\//];
133
+ /** Test paths read from one command by the order check (T-38-25). */
134
+ exports.MAX_ORDER_PATHS = 20;
135
+ /** Options that change where a command runs: the order check skips the command (T-38-27). */
136
+ exports.CWD_OPTIONS = ['--prefix', '--cwd', '--root', '--dir', '--chdir', '--workspace', '-C',
137
+ '--rootDir', '--project', '--projects', '--testFile'];
138
+ /** Verbs that name a path without running it, or write one; the order check skips their steps. */
139
+ exports.ORDER_SKIP_VERBS = ['grep', 'rg', 'echo', 'printf', 'test', '[', 'ls', 'cat', 'head', 'tail', 'wc',
140
+ 'git', 'cmp', 'diff', 'stat', 'find', 'tee', 'cp', 'mv', 'touch'];
141
+ /** Runners that pick a workspace, project or container: the order check skips the command (WR-04). */
142
+ exports.ORDER_SKIP_RUNNERS = ['nx', 'turbo', 'lerna', 'docker', 'docker-compose', 'podman'];
143
+ /** Over the cap: a plan-level over_cap blocker, never clean. */
144
+ exports.MAX_PLAN_CHARS = 200000;
145
+ /**
146
+ * A PLAN.md file over this many bytes is not read (38-UF-1). UTF-8 decodes to at least one
147
+ * UTF-16 unit per three bytes (a replacement character also covers at most three bytes), so
148
+ * a file over this size is over MAX_PLAN_CHARS characters and got `over_cap` when it was
149
+ * read; skipping the read keeps that finding.
150
+ */
151
+ exports.MAX_PLAN_BYTES = 3 * exports.MAX_PLAN_CHARS;
152
+ /** Over the cap: a task-level over_cap blocker. */
153
+ exports.MAX_COMMAND_CHARS = 4000;
154
+ /** Task names echoed back are sanitized and cut to this. */
155
+ exports.MAX_QUOTED_CHARS = 120;
156
+ // ─── Small linear helpers ─────────────────────────────────────────────────────
157
+ function mk(code, detail) {
158
+ return { code, severity: 'blocker', detail };
159
+ }
160
+ /** Whitespace runs collapse to one space. `\s+` alone has nothing to backtrack into. */
161
+ function collapse(s) {
162
+ return s.split(/\s+/).filter((w) => w !== '').join(' ');
163
+ }
164
+ /** A string echoed back to an agent: collapsed, sanitized and cut (T-38-03). */
165
+ function quoted(s, max = exports.MAX_QUOTED_CHARS) {
166
+ const flat = collapse(s.length > max * 4 ? s.slice(0, max * 4) : s);
167
+ const clean = (0, security_cjs_1.sanitizeForPrompt)(flat);
168
+ return clean.length > max ? clean.slice(0, max) : clean;
169
+ }
170
+ function isWordChar(ch) {
171
+ return /^[A-Za-z0-9_]$/.test(ch);
172
+ }
173
+ /** Surrounding backticks and quotes, backslashes and a leading `./` removed. */
174
+ function normPath(p) {
175
+ let s = p.trim();
176
+ let a = 0;
177
+ let b = s.length;
178
+ const wrap = '`\'"';
179
+ while (a < b && wrap.includes(s.charAt(a)))
180
+ a++;
181
+ while (b > a && wrap.includes(s.charAt(b - 1)))
182
+ b--;
183
+ s = s.slice(a, b).split('\\').join('/');
184
+ while (s.startsWith('./'))
185
+ s = s.slice(2);
186
+ return s;
187
+ }
188
+ function isTestPath(p) {
189
+ const s = normPath(p);
190
+ if (s.length === 0 || s.length > 1000)
191
+ return false;
192
+ return exports.TEST_PATH_PATTERNS.some((re) => re.test(s));
193
+ }
194
+ // ─── Commands ─────────────────────────────────────────────────────────────────
195
+ const ENTITY_PAIRS = [
196
+ ['&lt;', '<'], ['&gt;', '>'], ['&quot;', '"'], ['&amp;', '&'],
197
+ ];
198
+ /** Plans are XML, so `&amp;&amp;` is `&&`. Fixed-string replacements, `&amp;` last so it never double-decodes. */
199
+ function decodeEntities(s) {
200
+ let out = s;
201
+ for (const [from, to] of ENTITY_PAIRS)
202
+ out = out.split(from).join(to);
203
+ return out;
204
+ }
205
+ /**
206
+ * The command cut to MAX_COMMAND_CHARS, entities decoded, split on `&&`, `;`, `||`
207
+ * and newline. The alternation has no quantifier and no whitespace around the
208
+ * separators: `\s*&&\s*` costs 12-30 s on a long run of spaces (research Pitfall 3).
209
+ */
210
+ function segmentCommand(raw) {
211
+ return commandParts(raw).map((p) => p.text);
212
+ }
213
+ /**
214
+ * `s` without its shell comments (WR-01): an unquoted, unescaped `#` that starts a
215
+ * word (at the start, or after whitespace, `;`, `&`, `|`, `(` or `)`) runs to the end
216
+ * of its line, so `# npm test` runs nothing. Quotes are tracked, so `grep "# fail 0"`
217
+ * keeps its text, and `$#` or `${#x}` is not a comment; after an unclosed quote the
218
+ * rest is kept as it is. One character loop.
219
+ */
220
+ function stripComments(s) {
221
+ if (!s.includes('#'))
222
+ return s;
223
+ const keep = [];
224
+ let quote = '';
225
+ let start = 0;
226
+ for (let i = 0; i < s.length; i++) {
227
+ const c = s.charAt(i);
228
+ if (quote !== '') {
229
+ if (c === '\\' && quote === '"')
230
+ i++;
231
+ else if (c === quote)
232
+ quote = '';
233
+ continue;
234
+ }
235
+ if (c === '\\') {
236
+ i++;
237
+ continue;
238
+ }
239
+ if (c === '"' || c === "'") {
240
+ quote = c;
241
+ continue;
242
+ }
243
+ if (c === '#' && (i === 0 || /[\s;&|()]/.test(s.charAt(i - 1)))) {
244
+ keep.push(s.slice(start, i));
245
+ const nl = s.indexOf('\n', i);
246
+ if (nl === -1) {
247
+ start = s.length;
248
+ break;
249
+ }
250
+ start = nl;
251
+ i = nl - 1;
252
+ }
253
+ }
254
+ keep.push(s.slice(start));
255
+ return keep.join('');
256
+ }
257
+ /** Each non-empty segment with the separator before it (`''` for the first). One character loop. */
258
+ function commandParts(raw) {
259
+ return splitParts(commandText(raw));
260
+ }
261
+ /** The command cut to MAX_COMMAND_CHARS, entities decoded and comments dropped. */
262
+ function commandText(raw) {
263
+ const cut = raw.length > exports.MAX_COMMAND_CHARS ? raw.slice(0, exports.MAX_COMMAND_CHARS) : raw;
264
+ return stripComments(decodeEntities(cut));
265
+ }
266
+ /** `commandParts` over already decoded text. */
267
+ function splitParts(s) {
268
+ const out = [];
269
+ let sep = '';
270
+ let start = 0;
271
+ const flush = (end, next) => {
272
+ const text = s.slice(start, end).trim();
273
+ if (text !== '')
274
+ out.push({ sep, text });
275
+ // `npm test ||` + newline + `echo x`: the empty line between keeps the `||`.
276
+ if (text !== '' || (sep !== '||' && sep !== '&&'))
277
+ sep = next;
278
+ };
279
+ for (let i = 0; i < s.length; i++) {
280
+ const c = s.charAt(i);
281
+ const two = s.slice(i, i + 2);
282
+ if (two === '&&' || two === '||') {
283
+ flush(i, two);
284
+ start = i + 2;
285
+ i++;
286
+ continue;
287
+ }
288
+ if (c === ';' || c === '\n') {
289
+ flush(i, c);
290
+ start = i + 1;
291
+ }
292
+ }
293
+ flush(s.length, '');
294
+ return out;
295
+ }
296
+ /** True when `needle` occurs in `c` and the next character is the end or a non-word character. */
297
+ function hasNeedle(c, needles, leadingBoundary) {
298
+ for (const needle of needles) {
299
+ let from = 0;
300
+ for (;;) {
301
+ const i = c.indexOf(needle, from);
302
+ if (i === -1)
303
+ break;
304
+ const next = c.charAt(i + needle.length);
305
+ const prev = i === 0 ? '' : c.charAt(i - 1);
306
+ const endOk = next === '' || !isWordChar(next);
307
+ const startOk = !leadingBoundary || prev === '' || !isWordChar(prev);
308
+ if (endOk && startOk)
309
+ return true;
310
+ from = i + 1;
311
+ }
312
+ }
313
+ return false;
314
+ }
315
+ const NEUTRAL_VERBS = new Set(['cd', 'export', 'set']);
316
+ const EXISTENCE_FLAGS = new Set(['-f', '-e', '-d', '-s']);
317
+ function segmentKind(seg) {
318
+ const toks = collapse(seg).split(' ');
319
+ const t0 = toks[0] ?? '';
320
+ const t1 = toks[1] ?? '';
321
+ if (NEUTRAL_VERBS.has(t0))
322
+ return 'neutral';
323
+ // Only `VAR=val` words, none running a command substitution: an assignment, nothing runs (WR-01).
324
+ if (isAssignmentOnly(seg))
325
+ return 'neutral';
326
+ if ((t0 === 'test' || t0 === '[' || t0 === '[[') && EXISTENCE_FLAGS.has(t1))
327
+ return 'existence_only';
328
+ if (t0 === 'ls')
329
+ return 'existence_only';
330
+ // A pipeline stays one segment: `echo x | node cli.cjs | grep -qx y` feeds stdin
331
+ // to a real check, so an echo or printf is echo_only only when nothing reads it (WR-01).
332
+ if ((t0 === 'echo' || t0 === 'printf' || t0 === 'env' || t0 === 'printenv') && seg.includes('|'))
333
+ return null;
334
+ if (t0 === 'echo' || t0 === 'printf' || t0 === 'true' || t0 === ':' || t0 === '/bin/true' || t0 === '/usr/bin/true') {
335
+ return 'echo_only';
336
+ }
337
+ // `env` with nothing left to run, and a bare `printenv`, only print the environment (WR-01).
338
+ if (t0 === 'printenv' && toks.length === 1)
339
+ return 'echo_only';
340
+ if (t0 === 'env' && stepWords(seg).rest.length === 0)
341
+ return 'echo_only';
342
+ if ((t0 === 'grep' || t0 === 'rg') && toks.slice(1).some((t) => isTestPath(t)))
343
+ return 'grep_test_file';
344
+ return null;
345
+ }
346
+ /** True when every word of `seg` is a `VAR=val` assignment and none runs a command substitution. */
347
+ function isAssignmentOnly(seg) {
348
+ if (seg.includes('$(') || seg.includes('`'))
349
+ return false;
350
+ const toks = collapse(seg).split(' ').filter((t) => t !== '');
351
+ return toks.length > 0 && toks.every((t) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t));
352
+ }
353
+ /** Commands that check nothing about a test run: as its fallback or last step they replace its exit status. */
354
+ const NON_CHECKING_VERBS = new Set(['cat', 'head', 'tail', 'wc', 'pwd', 'date', 'sleep', 'env', 'printenv',
355
+ 'whoami', 'sort', 'uniq', 'tee']);
356
+ /** `git` subcommands that exit 0 whatever the tree holds; `diff` only without `--exit-code`, `--quiet` or `--check`. */
357
+ const NON_CHECKING_GIT = new Set(['status', 'log', 'show', 'branch', 'add', 'diff']);
358
+ /**
359
+ * `sed`, `awk` and `git` steps that check nothing (WR-01): an `awk` without `exit`, a
360
+ * `sed` without a `q`/`Q` exit code, and a read-only `git` subcommand. An `awk` that
361
+ * exits 1 on a red line or a `git diff --exit-code` is a check and keeps the status.
362
+ */
363
+ function toolChecksNothing(rest) {
364
+ const t0 = rest[0] ?? '';
365
+ const text = rest.join(' ');
366
+ if (t0 === 'awk')
367
+ return !/\bexit\b/.test(text);
368
+ if (t0 === 'sed')
369
+ return !/[qQ] ?[0-9]/.test(text);
370
+ if (t0 === 'git') {
371
+ const sub = rest.slice(1).find((w) => !w.startsWith('-')) ?? '';
372
+ if (!NON_CHECKING_GIT.has(sub))
373
+ return false;
374
+ return !rest.some((w) => w === '--exit-code' || w === '--quiet' || w === '--check');
375
+ }
376
+ return false;
377
+ }
378
+ /**
379
+ * True when `seg`, run as an `||` fallback or as the last `;` / newline step, ends
380
+ * with status 0 whatever the test run did: any vacuous segment kind, a
381
+ * non-checking command (`cat`, `tail`, ...) or `! false` (WR-05). A real command
382
+ * (`node`, `npm`, `pytest`, `go`, `exit $?`, `exit 1`) keeps the status.
383
+ */
384
+ function discardsStatus(seg) {
385
+ // A pipeline's status is its last stage's (WR-01): it discards the status only when
386
+ // its first and its last stage both check nothing, so `cat out | grep -q x` and
387
+ // `cat in.json | node check.js` keep it. A real first stage piped to `tail` stays a
388
+ // named residual.
389
+ const stages = pipeStages(seg);
390
+ if (stages.length > 1)
391
+ return stepDiscards(stages[0] ?? '') && stepDiscards(stages[stages.length - 1] ?? '');
392
+ return stepDiscards(seg);
393
+ }
394
+ /** `seg` split on each `|` that is outside quotes, not escaped and not part of `||`. One character loop. */
395
+ function pipeStages(seg) {
396
+ const out = [];
397
+ let quote = '';
398
+ let start = 0;
399
+ for (let i = 0; i < seg.length; i++) {
400
+ const c = seg.charAt(i);
401
+ if (quote !== '') {
402
+ if (c === '\\' && quote === '"')
403
+ i++;
404
+ else if (c === quote)
405
+ quote = '';
406
+ continue;
407
+ }
408
+ if (c === '\\') {
409
+ i++;
410
+ continue;
411
+ }
412
+ if (c === '"' || c === "'") {
413
+ quote = c;
414
+ continue;
415
+ }
416
+ if (c === '|' && seg.charAt(i + 1) !== '|' && seg.charAt(i - 1) !== '|') {
417
+ out.push(seg.slice(start, i));
418
+ start = i + 1;
419
+ }
420
+ }
421
+ out.push(seg.slice(start));
422
+ return out;
423
+ }
424
+ /** `env` options that take the next word as their argument. */
425
+ const ENV_ARG_OPTIONS = new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']);
426
+ /**
427
+ * The words of one pipeline stage after its `env [-opt] [VAR=val ...]` or bare
428
+ * `VAR=val` prefix. `viaEnv` is true for an `env` prefix; `chdir` is true when that
429
+ * prefix changed the working directory (`-C`, `--chdir`).
430
+ */
431
+ function stepWords(step) {
432
+ const toks = collapse(step).split(' ').filter((t) => t !== '');
433
+ const viaEnv = toks[0] === 'env';
434
+ let chdir = false;
435
+ let i = viaEnv ? 1 : 0;
436
+ while (i < toks.length) {
437
+ const t = toks[i] ?? '';
438
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t))
439
+ i++;
440
+ else if (viaEnv && ENV_ARG_OPTIONS.has(t)) {
441
+ if (t === '-C' || t === '--chdir')
442
+ chdir = true;
443
+ i += 2;
444
+ }
445
+ else if (viaEnv && t.startsWith('-')) {
446
+ if (t.startsWith('--chdir=') || (t.startsWith('-C') && !t.startsWith('--')))
447
+ chdir = true;
448
+ i++;
449
+ }
450
+ else
451
+ break;
452
+ }
453
+ return { rest: toks.slice(i), viaEnv, chdir };
454
+ }
455
+ /**
456
+ * One pipeline stage, judged by the command it runs: a leading `env [-opt] [VAR=val ...]`
457
+ * or bare `VAR=val` prefix is dropped first, because `env CI=1 node --test x` returns
458
+ * node's status (WR-01). `env` with nothing left to run only prints, so it checks nothing.
459
+ */
460
+ function stepDiscards(step) {
461
+ const { rest, viaEnv } = stepWords(step);
462
+ if (rest.length === 0)
463
+ return viaEnv;
464
+ const kind = segmentKind(rest.join(' '));
465
+ if (kind === 'echo_only' || kind === 'existence_only' || kind === 'grep_test_file')
466
+ return true;
467
+ const t0 = rest[0] ?? '';
468
+ // The stage has no unquoted pipe, so an echo here feeds nothing (`echo "a|b"`).
469
+ if (t0 === 'echo' || t0 === 'printf')
470
+ return true;
471
+ if (t0 === '!')
472
+ return rest[1] === 'false' || rest[1] === '/bin/false' || rest[1] === '/usr/bin/false';
473
+ return NON_CHECKING_VERBS.has(t0) || toolChecksNothing(rest);
474
+ }
475
+ /** `set -e`, `set -euo pipefail` or `set -o errexit`: a failing step stops the script. */
476
+ function setsErrexit(seg) {
477
+ const toks = collapse(seg).split(' ');
478
+ if (toks[0] !== 'set')
479
+ return false;
480
+ for (let i = 1; i < toks.length; i++) {
481
+ if (toks[i] === '-o' && toks[i + 1] === 'errexit')
482
+ return true;
483
+ if (/^-[A-Za-z]*e[A-Za-z]*$/.test(toks[i]))
484
+ return true;
485
+ }
486
+ return false;
487
+ }
488
+ /** `set -o pipefail` or `set -euo pipefail`: a failing pipeline stage fails the pipeline. */
489
+ function setsPipefail(seg) {
490
+ const toks = collapse(seg).split(' ');
491
+ if (toks[0] !== 'set')
492
+ return false;
493
+ for (let i = 1; i < toks.length - 1; i++) {
494
+ if (/^-[A-Za-z]*o$/.test(toks[i]) && toks[i + 1] === 'pipefail')
495
+ return true;
496
+ }
497
+ return false;
498
+ }
499
+ /** `sh -c`, `bash -c`, `bash -ec`, `/bin/sh -c` and the like: a child shell running a script argument. */
500
+ const CHILD_SHELL = /(?:^|[\s;&|(/])(?:ba|z|da|k)?sh -[A-Za-z]*c(?: |$)/;
501
+ /** True when a `;` or a newline sits inside quotes. Same quote rules as `pipeStages`. One character loop. */
502
+ function separatorInQuotes(s) {
503
+ let quote = '';
504
+ for (let i = 0; i < s.length; i++) {
505
+ const c = s.charAt(i);
506
+ if (quote !== '') {
507
+ if (c === '\\' && quote === '"')
508
+ i++;
509
+ else if (c === quote)
510
+ quote = '';
511
+ else if (c === ';' || c === '\n')
512
+ return true;
513
+ continue;
514
+ }
515
+ if (c === '\\') {
516
+ i++;
517
+ continue;
518
+ }
519
+ if (c === '"' || c === "'")
520
+ quote = c;
521
+ }
522
+ return false;
523
+ }
524
+ /**
525
+ * True only when `set -e` really stops the script on every failing step (CR-01). Bash
526
+ * does not exit on a failure in an `&&` / `||` list, in a pipeline without `pipefail`,
527
+ * in a `!`, `if`, `elif`, `while` or `until` condition, in a background job, or inside
528
+ * the `$(...)` of an `export` / `local` / `declare` / `readonly` / `typeset`; and
529
+ * `set +e` turns errexit off. Any such shape anywhere in the command withdraws the
530
+ * exemption, so when in doubt the `; true` and last-step checks still run.
531
+ */
532
+ function errexitCovers(parts, pipefail, text) {
533
+ // The split ignores quotes, so a `;` inside `bash -c "...; echo ok"` reads as a
534
+ // top-level step, and errexit does not reach a child shell's script or a `<(...)`
535
+ // process substitution (WR-02): either shape withdraws the exemption. A child shell
536
+ // whose quoted script has no `;` or newline returns that script's status, so it keeps it.
537
+ if (text.includes('<('))
538
+ return false;
539
+ if (CHILD_SHELL.test(collapse(text)) && separatorInQuotes(text))
540
+ return false;
541
+ for (const p of parts) {
542
+ if (p.sep === '&&' || p.sep === '||')
543
+ return false;
544
+ const flat = collapse(p.text);
545
+ if (!pipefail && /(?:^|[^|])\|(?:[^|]|$)/.test(flat))
546
+ return false;
547
+ if (/^(?:!|(?:if|elif|while|until)\b)/.test(flat))
548
+ return false;
549
+ // A lone `&` (not `&&`, `>&`, `&>` or `<&`) sends the job to the background.
550
+ if (/(?:^|[^<>&])&(?:[^&>]|$)/.test(flat))
551
+ return false;
552
+ if (/^(?:export|local|declare|readonly|typeset)\b/.test(flat) && (flat.includes('$(') || flat.includes('`')))
553
+ return false;
554
+ if (/^set\b/.test(flat) && / \+[A-Za-z]*e|\+o errexit|\+o pipefail/.test(flat))
555
+ return false;
556
+ }
557
+ return true;
558
+ }
559
+ /** `exit N` or `return N` with N non-zero, or with a `$VAR` operand, or `false`: a step that fails the script. */
560
+ const LOUD_STEP = /^(?:(?:exit|return) (?:"?[1-9][0-9]*"?|"?\$\{?[A-Za-z_])|false(?:$| ))/;
561
+ /** `exit $?` as the first step of an `||` fallback: `$?` is still the failed run's status. */
562
+ const EXIT_STATUS = /^(?:exit|return) "?\$\{?\?/;
563
+ /** A step's words after a leading `{`, `then` or `else`. */
564
+ function guardStep(text) {
565
+ let t = collapse(text);
566
+ for (;;) {
567
+ if (t.startsWith('{'))
568
+ t = t.slice(1).trimStart();
569
+ else if (t.startsWith('then '))
570
+ t = t.slice(5);
571
+ else if (t.startsWith('else '))
572
+ t = t.slice(5);
573
+ else
574
+ return t;
575
+ }
576
+ }
577
+ /**
578
+ * True when the last real step is followed by a guard that exits the script non-zero
579
+ * when that step failed, and only steps that check nothing follow the guard, so the
580
+ * trailing `echo ok` cannot replace a red run's status (WR-03). A guard is:
581
+ * - `|| exit 1`, `|| exit $?`, `|| exit $rc`, `|| return 1` or `|| false`;
582
+ * - `|| { ...; exit 1; }`: a brace group, which runs in this shell, holding a loud
583
+ * step (`exit $?` only as its first step, since later `$?` is the echo's status);
584
+ * - `if [ $? -ne 0 ]; then exit 1; fi`: an `if` on `$?` whose body holds a loud step.
585
+ * A `( ... )` subshell is not a guard: its `exit` leaves only the subshell. A guard
586
+ * on an earlier step (`npm run build || exit 1; node --test x; echo ok`) does not
587
+ * count. The closing `}` / `fi` and the loud steps are indexed once, so the scan is
588
+ * linear in the number of parts.
589
+ */
590
+ function failsLoudly(parts) {
591
+ const n = parts.length;
592
+ const flats = parts.map((p) => collapse(p.text));
593
+ const inert = (k) => {
594
+ const text = parts[k]?.text ?? '';
595
+ return discardsStatus(text) || segmentKind(text) === 'neutral' || isAssignmentOnly(text);
596
+ };
597
+ // The first index from which every part is inert: the guard must end there or later.
598
+ let tail = n;
599
+ while (tail > 0 && inert(tail - 1))
600
+ tail--;
601
+ // nextClose[k] / nextFi[k]: the first index >= k that closes a brace group / is `fi`;
602
+ // loudBefore[k]: loud steps in parts[0..k).
603
+ const nextClose = new Array(n + 1).fill(-1);
604
+ const nextFi = new Array(n + 1).fill(-1);
605
+ const loudBefore = new Array(n + 1).fill(0);
606
+ for (let k = 0; k < n; k++) {
607
+ const flat = flats[k] ?? '';
608
+ const loud = LOUD_STEP.test(guardStep(flat.endsWith('}') ? flat.slice(0, -1) : flat));
609
+ loudBefore[k + 1] = (loudBefore[k] ?? 0) + (loud ? 1 : 0);
610
+ }
611
+ for (let k = n - 1; k >= 0; k--) {
612
+ const flat = flats[k] ?? '';
613
+ nextClose[k] = flat.endsWith('}') ? k : (nextClose[k + 1] ?? -1);
614
+ nextFi[k] = flat === 'fi' ? k : (nextFi[k + 1] ?? -1);
615
+ }
616
+ const louds = (from, to) => (loudBefore[to] ?? 0) - (loudBefore[from] ?? 0);
617
+ for (let i = 1; i < n; i++) {
618
+ const head = parts[i];
619
+ const flat = flats[i] ?? '';
620
+ if (head === undefined)
621
+ continue;
622
+ let end = -1;
623
+ if (head.sep === '||' && !flat.startsWith('{')) {
624
+ const first = guardStep(flat);
625
+ if (LOUD_STEP.test(first) || EXIT_STATUS.test(first))
626
+ end = i + 1;
627
+ }
628
+ else if (head.sep === '||') {
629
+ const close = nextClose[i] ?? -1;
630
+ const first = guardStep(flat);
631
+ if (close !== -1 && (LOUD_STEP.test(first) || EXIT_STATUS.test(first) || louds(i + 1, close + 1) > 0))
632
+ end = close + 1;
633
+ }
634
+ else if (/^if /.test(flat) && flat.includes('$?')) {
635
+ const fi = nextFi[i + 1] ?? -1;
636
+ if (fi !== -1 && louds(i + 1, fi) > 0)
637
+ end = fi + 1;
638
+ }
639
+ if (end === -1 || end < tail)
640
+ continue;
641
+ const before = parts[i - 1]?.text ?? '';
642
+ if (segmentKind(before) === null && !discardsStatus(before))
643
+ return true;
644
+ }
645
+ return false;
646
+ }
647
+ /** `exit` with a literal zero, bare or quoted, as a whole word. Linear: no nested quantifier. */
648
+ const EXIT_ZERO = /(?:^|[^A-Za-z0-9_])exit (["']?)0+\1(?![A-Za-z0-9_])/;
649
+ /**
650
+ * The vacuous kind of a verify command, or null. `|| true`, `exit 0` and the
651
+ * pass-with-no-tests flags are vacuous anywhere; so is `; true` unless `set -e` runs
652
+ * first. The existence, echo and grep-over-test-file kinds are vacuous only when
653
+ * every non-neutral segment is, so `test -f x && node --test y` is a real check.
654
+ * A command with no non-neutral segment (empty, or only `cd`, `export`, `set`) is
655
+ * `no_command`. The exit status is also discarded by an `||` fallback, or a final segment after
656
+ * `;` or a newline, that checks nothing (`discardsStatus`, CR-03, WR-05); a `set -e`
657
+ * before the first real step keeps the status through `;` where errexit really stops
658
+ * the script (`errexitCovers`). A pipe to `tail`,
659
+ * `head`, `wc` or `tee` is not flagged (a named residual).
660
+ */
661
+ function classifyCommand(raw) {
662
+ const cut = raw.length > exports.MAX_COMMAND_CHARS ? raw.slice(0, exports.MAX_COMMAND_CHARS) : raw;
663
+ const flat = collapse(stripComments(decodeEntities(cut)));
664
+ const parts = commandParts(cut);
665
+ let errexit = false;
666
+ let pipefail = false;
667
+ for (const part of parts) {
668
+ if (segmentKind(part.text) !== 'neutral')
669
+ break;
670
+ if (setsErrexit(part.text))
671
+ errexit = true;
672
+ if (setsPipefail(part.text))
673
+ pipefail = true;
674
+ }
675
+ errexit = errexit && errexitCovers(parts, pipefail, stripComments(decodeEntities(cut)));
676
+ if (hasNeedle(flat, ['|| true', '||true'], false))
677
+ return 'or_true';
678
+ if (!errexit && hasNeedle(flat, ['; true', ';true'], false))
679
+ return 'semicolon_true';
680
+ // `exit 0`, `exit "0"`, `exit '0'` and `exit 00` (WR-01).
681
+ if (EXIT_ZERO.test(flat))
682
+ return 'exit_zero';
683
+ if (flat.includes('--passWithNoTests') || flat.includes('--pass-with-no-tests'))
684
+ return 'pass_with_no_tests';
685
+ let first = null;
686
+ let judged = 0;
687
+ let allVacuous = true;
688
+ for (const part of parts) {
689
+ const kind = segmentKind(part.text);
690
+ if (kind === 'neutral')
691
+ continue;
692
+ judged++;
693
+ if (kind === null)
694
+ allVacuous = false;
695
+ else if (first === null)
696
+ first = kind;
697
+ }
698
+ if (judged > 0 && allVacuous)
699
+ return first;
700
+ // Empty, or only `cd`, `export` and `set`: the command runs nothing (WR-05).
701
+ if (judged === 0)
702
+ return 'no_command';
703
+ if (parts.some((p) => p.sep === '||' && discardsStatus(p.text)))
704
+ return 'or_true';
705
+ const last = parts[parts.length - 1];
706
+ // A bare `VAR=val` last step is status 0 (WR-01). As an `||` fallback it is the
707
+ // `cmd || rc=1; ...; exit $rc` idiom, which records the failure, so only the last step counts.
708
+ if (!errexit && last !== undefined && (last.sep === ';' || last.sep === '\n')
709
+ && (discardsStatus(last.text) || isAssignmentOnly(last.text)) && !failsLoudly(parts)) {
710
+ return 'semicolon_true';
711
+ }
712
+ return null;
713
+ }
714
+ // ─── Behaviour statements ─────────────────────────────────────────────────────
715
+ /** Lower-cased, list markers and backticks removed, whitespace collapsed, trailing `.`/`!` trimmed. */
716
+ function normaliseStatement(raw) {
717
+ const lines = raw.split('\n').map((line) => {
718
+ let s = line.trim();
719
+ if (s.startsWith('- ') || s.startsWith('* ') || s.startsWith('+ '))
720
+ s = s.slice(2);
721
+ return s;
722
+ });
723
+ let flat = collapse(lines.join(' ').toLowerCase().split('`').join(''));
724
+ let end = flat.length;
725
+ while (end > 0 && (flat.charAt(end - 1) === '.' || flat.charAt(end - 1) === '!'))
726
+ end--;
727
+ flat = flat.slice(0, end).trim();
728
+ return flat;
729
+ }
730
+ /** Dropped before the denylist compare, so "All of the tests pass now." is `tests pass`. */
731
+ const BEHAVIOR_FILLER = new Set(['all', 'of', 'the', 'a', 'an', 'now', 'correctly', 'successfully', 'properly',
732
+ 'fully', 'just', 'still', 'should', 'will']);
733
+ /** A denylist phrase may follow at most this many other words ("the feature is implemented"). */
734
+ const GENERIC_LEAD_WORDS = 2;
735
+ /**
736
+ * A template placeholder left in the statement: a `[...]` of letters and spaces
737
+ * (with `:`, `→`, `->` or `-`) holding at least two words, outside backticks and not
738
+ * a markdown link. `[a, b]`, `['x']` and `[1]` are code, not placeholders (WR-03).
739
+ */
740
+ function hasPlaceholder(raw) {
741
+ const prose = raw.split('`').filter((_, i) => i % 2 === 0).join(' ');
742
+ const re = /\[([A-Za-z][A-Za-z :→>-]{0,200})\](?!\()/g;
743
+ for (let m = re.exec(prose); m !== null; m = re.exec(prose)) {
744
+ if ((m[1] ?? '').trim().includes(' '))
745
+ return true;
746
+ }
747
+ return false;
748
+ }
749
+ /** The statement's words with filler dropped equal a denylist phrase, after at most GENERIC_LEAD_WORDS other words. */
750
+ function isGenericStatement(norm) {
751
+ const core = norm.split(' ').filter((w) => !BEHAVIOR_FILLER.has(w));
752
+ for (const phrase of exports.GENERIC_BEHAVIORS) {
753
+ const p = phrase.split(' ').filter((w) => !BEHAVIOR_FILLER.has(w));
754
+ if (p.length === 0 || core.length < p.length || core.length > p.length + GENERIC_LEAD_WORDS)
755
+ continue;
756
+ if (core.slice(core.length - p.length).join(' ') === p.join(' '))
757
+ return true;
758
+ }
759
+ return false;
760
+ }
761
+ /** D-01 / D-02: absent, empty, a template placeholder, too short, or a generic phrase. */
762
+ function checkBehavior(raw) {
763
+ if (raw === null)
764
+ return mk('behavior_missing', 'absent');
765
+ if (raw.trim() === '')
766
+ return mk('behavior_missing', 'empty');
767
+ if (hasPlaceholder(raw))
768
+ return mk('behavior_generic', 'placeholder');
769
+ const norm = normaliseStatement(raw);
770
+ const words = norm === '' ? 0 : norm.split(' ').length;
771
+ if (norm.length < exports.BEHAVIOR_MIN_CHARS || words < exports.BEHAVIOR_MIN_WORDS)
772
+ return mk('behavior_generic', 'too_short');
773
+ if (exports.GENERIC_BEHAVIORS.includes(norm) || isGenericStatement(norm)) {
774
+ return mk('behavior_generic', 'denylisted');
775
+ }
776
+ return null;
777
+ }
778
+ // ─── Plan parsing ─────────────────────────────────────────────────────────────
779
+ function isOpenLine(line, open) {
780
+ const t = line.trimStart();
781
+ if (t.length <= open.length)
782
+ return false;
783
+ if (t.slice(0, open.length).toLowerCase() !== open)
784
+ return false;
785
+ const ch = t.charAt(open.length);
786
+ return ch === '>' || ch === ' ' || ch === '\t';
787
+ }
788
+ /** Units of `tag` by line scan: an opening line, then the first line carrying the close. One failed search stops the scan. */
789
+ function scanUnits(lines, tag) {
790
+ const open = `<${tag}`;
791
+ const close = `</${tag}>`;
792
+ const units = [];
793
+ let i = 0;
794
+ while (i < lines.length) {
795
+ if (!isOpenLine(lines[i], open)) {
796
+ i++;
797
+ continue;
798
+ }
799
+ const at = lines[i].toLowerCase().indexOf(open);
800
+ let end = lines[i].indexOf(close, at < 0 ? 0 : at) === -1 ? -1 : i;
801
+ for (let j = i + 1; end === -1 && j < lines.length; j++) {
802
+ if (lines[j].includes(close))
803
+ end = j;
804
+ }
805
+ if (end === -1)
806
+ return { units, unterminated: true };
807
+ units.push([i, end]);
808
+ i = end + 1;
809
+ }
810
+ return { units, unterminated: false };
811
+ }
812
+ /**
813
+ * The first element named `name` whose opening tag starts a line (left-trimmed), up
814
+ * to its closing tag. A mention inside prose is not an element. Lines in `skip`
815
+ * (the task's action body) are ignored.
816
+ */
817
+ function findElement(lines, name, skip) {
818
+ const open = `<${name}`;
819
+ const close = `</${name}>`;
820
+ for (let i = 0; i < lines.length; i++) {
821
+ if (skip && i >= skip[0] && i <= skip[1])
822
+ continue;
823
+ if (!isOpenLine(lines[i], open))
824
+ continue;
825
+ const t = lines[i].trimStart();
826
+ const gt = t.indexOf('>');
827
+ if (gt === -1)
828
+ continue;
829
+ const rest = t.slice(gt + 1);
830
+ const sameLine = rest.indexOf(close);
831
+ if (sameLine !== -1)
832
+ return { inner: rest.slice(0, sameLine), start: i, end: i };
833
+ const parts = [rest];
834
+ for (let j = i + 1; j < lines.length; j++) {
835
+ const k = lines[j].indexOf(close);
836
+ if (k !== -1) {
837
+ parts.push(lines[j].slice(0, k));
838
+ return { inner: parts.join('\n'), start: i, end: j };
839
+ }
840
+ parts.push(lines[j]);
841
+ }
842
+ return null;
843
+ }
844
+ return null;
845
+ }
846
+ function parseFiles(inner) {
847
+ if (inner === null)
848
+ return [];
849
+ const out = [];
850
+ for (const part of inner.split(/[\n,]/)) {
851
+ let s = part.trim();
852
+ if (s.startsWith('- ') || s.startsWith('* '))
853
+ s = s.slice(2);
854
+ s = s.split('`').join('').trim();
855
+ if (s !== '')
856
+ out.push(s);
857
+ }
858
+ return out;
859
+ }
860
+ function attr(openLine, name) {
861
+ const m = new RegExp(`\\b${name}\\s{0,3}=\\s{0,3}["']([^"']{0,200})["']`, 'i').exec(openLine);
862
+ return m ? (m[1] ?? '') : null;
863
+ }
864
+ function parseUnit(unitLines, index, isFeature) {
865
+ const openLine = unitLines[0] ?? '';
866
+ const typeAttr = attr(openLine, 'type');
867
+ const tdd = isFeature || (attr(openLine, 'tdd') ?? '').toLowerCase() === 'true';
868
+ const actionEl = findElement(unitLines, 'action');
869
+ const skip = actionEl ? [actionEl.start, actionEl.end] : undefined;
870
+ const nameEl = findElement(unitLines, 'name', skip);
871
+ const filesEl = findElement(unitLines, 'files', skip);
872
+ const behaviorEl = findElement(unitLines, 'behavior', skip);
873
+ const verifyEl = findElement(unitLines, 'verify', skip);
874
+ // An `<automated>` element that is present but empty stays `''`, so the verb reports
875
+ // it as `no_command` (WR-05); no command at all stays null.
876
+ let command = null;
877
+ if (verifyEl) {
878
+ const automated = findElement(verifyEl.inner.split('\n'), 'automated');
879
+ if (automated) {
880
+ command = automated.inner.trim();
881
+ }
882
+ else {
883
+ const text = (/<[A-Za-z]/.test(verifyEl.inner) ? '' : verifyEl.inner).trim();
884
+ command = text === '' ? null : text;
885
+ }
886
+ }
887
+ const filesInner = filesEl ? filesEl.inner : null;
888
+ const behavior = behaviorEl ? behaviorEl.inner : null;
889
+ return {
890
+ index,
891
+ name: nameEl && nameEl.inner.trim() !== '' ? quoted(nameEl.inner) : `Task ${index}`,
892
+ type: quoted(typeAttr ?? 'auto', 40),
893
+ tdd,
894
+ behavior,
895
+ files: parseFiles(filesInner),
896
+ command,
897
+ action: actionEl ? actionEl.inner : '',
898
+ // The D-12 predicate runs its regexes over whole input; give it only the opening
899
+ // line, the files and the behaviour, never the action prose (research Pitfall 5).
900
+ snippet: `${openLine}\n<files>${filesInner ?? ''}</files>\n<behavior>${behavior ?? ''}</behavior>`,
901
+ };
902
+ }
903
+ /**
904
+ * Task units of a PLAN.md. A `type: tdd` plan holds `<feature>` blocks instead of
905
+ * task elements; each is one unit. Over the size cap, an unterminated element or a
906
+ * markdown-only plan is a plan-level finding, never a clean parse.
907
+ */
908
+ function parsePlanTasks(content) {
909
+ if (content.length > exports.MAX_PLAN_CHARS)
910
+ return { tasks: [], findings: [mk('over_cap', 'plan')] };
911
+ const lines = content.split('\r\n').join('\n').split('\n');
912
+ const findings = [];
913
+ let scan = scanUnits(lines, 'task');
914
+ let isFeature = false;
915
+ if (scan.units.length === 0 && !scan.unterminated) {
916
+ scan = scanUnits(lines, 'feature');
917
+ isFeature = scan.units.length > 0 || scan.unterminated;
918
+ }
919
+ if (scan.unterminated)
920
+ findings.push(mk('unreadable', 'unterminated_task'));
921
+ const tasks = scan.units.map(([start, end], i) => parseUnit(lines.slice(start, end + 1), i + 1, isFeature));
922
+ // Any plan with no task element is a finding, whatever its prose looks like
923
+ // (`## Task 1`, `### 1. Build`, `**Task 1:**`): never a clean parse (WR-04).
924
+ if (tasks.length === 0 && !scan.unterminated)
925
+ findings.push(mk('behavior_missing', 'no_xml_tasks'));
926
+ return { tasks, findings };
927
+ }
928
+ /**
929
+ * The `wave:` value of the frontmatter: the content starts with a `---` line, and at
930
+ * most 200 following lines are read up to the closing `---`. Null when absent or not
931
+ * 1 to 4 digits. Reads only the head of the plan, one line at a time.
932
+ */
933
+ function planWave(content) {
934
+ const head = (content.length > 50000 ? content.slice(0, 50000) : content).split('\r\n').join('\n').split('\n', 202);
935
+ if ((head[0] ?? '').trimEnd() !== '---')
936
+ return null;
937
+ for (let i = 1; i < head.length && i <= 200; i++) {
938
+ const line = head[i] ?? '';
939
+ if (line.trimEnd() === '---')
940
+ return null;
941
+ const m = /^wave:\s{0,3}["']?([0-9]{1,4})(?![0-9])/.exec(line);
942
+ if (m)
943
+ return Number(m[1]);
944
+ }
945
+ return null;
946
+ }
947
+ function fileKeys(entry) {
948
+ const whole = normPath(entry);
949
+ const first = normPath(entry.trim().split(/\s/)[0] ?? '');
950
+ return first === whole ? [whole] : [whole, first];
951
+ }
952
+ function parsePlan(plan) {
953
+ const id = quoted(plan.id, 60);
954
+ const base = { plan, id, tasks: [], findings: [], firstPos: new Map(), wave: planWave(plan.content) };
955
+ if (plan.overCap === true) {
956
+ base.findings.push(mk('over_cap', 'plan'));
957
+ return base;
958
+ }
959
+ if (plan.content.length === 0) {
960
+ base.findings.push(mk('unreadable', 'read_error'));
961
+ return base;
962
+ }
963
+ const parsed = parsePlanTasks(plan.content);
964
+ base.tasks = parsed.tasks;
965
+ base.findings = parsed.findings;
966
+ parsed.tasks.forEach((task, pos) => {
967
+ for (const entry of task.files) {
968
+ for (const key of fileKeys(entry)) {
969
+ if (key !== '' && !base.firstPos.has(key))
970
+ base.firstPos.set(key, pos);
971
+ }
972
+ }
973
+ });
974
+ return base;
975
+ }
976
+ function cleanToken(t) {
977
+ const lead = '`\'"([{';
978
+ const trail = '`\'")].,;:!?}';
979
+ let a = 0;
980
+ let b = t.length;
981
+ while (a < b && lead.includes(t.charAt(a)))
982
+ a++;
983
+ while (b > a && trail.includes(t.charAt(b - 1)))
984
+ b--;
985
+ return t.slice(a, b);
986
+ }
987
+ /**
988
+ * A MISSING marker is a command that STARTS with the word `MISSING` after optional
989
+ * wrapping, list or quote characters (`` `MISSING … ` ``, `- MISSING …`,
990
+ * `(MISSING …)`, `> MISSING …`, `"missing: …"`). The all-caps word may be followed
991
+ * by the end or any character but a word character or `=` (`MISSING.`, `MISSING-…`,
992
+ * WR-03); any other case only by the end, a space, `:`, a dash or a quote (WR-02).
993
+ * Any other command, including one that merely contains the word (`echo MISSING x
994
+ * || true`, `--expect-MISSING-key`), starts with a longer word (`missing-deps.sh`,
995
+ * `MISSINGNO`) or assigns a variable (`MISSING=1 cmd`), is classified as a command
996
+ * (CR-01). Every marker is a finding (`missingFinding`), so a shell suffix
997
+ * (`MISSING x || true`) cannot make one pass.
998
+ */
999
+ const MISSING_MARKER_CAPS = /^[`'"*\-\s(>]*MISSING(?![A-Za-z0-9_=])/;
1000
+ const MISSING_MARKER = /^[`'"*\-\s(>]*missing(?:$|[\s:—–`'"])/i;
1001
+ function isMissingMarker(command) {
1002
+ return MISSING_MARKER_CAPS.test(command) || MISSING_MARKER.test(command);
1003
+ }
1004
+ /**
1005
+ * The test-file paths a `MISSING — Wave 0 must create {file} first` marker names:
1006
+ * only the tokens between `create` and `first`, so a trailing remark that names a
1007
+ * second file is not a path the marker asks for. Without `create`, every token
1008
+ * after `MISSING`; without `first`, every token to the end.
1009
+ */
1010
+ function markerPaths(command) {
1011
+ const toks = command.trim().split(/\s+/).slice(1).map(cleanToken);
1012
+ const createAt = toks.indexOf('create');
1013
+ const from = createAt === -1 ? 0 : createAt + 1;
1014
+ const firstAt = toks.indexOf('first', from);
1015
+ const out = [];
1016
+ for (const tok of toks.slice(from, firstAt === -1 ? toks.length : firstAt)) {
1017
+ if (tok === '' || out.includes(tok))
1018
+ continue;
1019
+ if (isTestPath(tok) || tok.includes('/') || /^[^\s]+\.[A-Za-z0-9]{2,8}$/.test(tok))
1020
+ out.push(tok);
1021
+ }
1022
+ return out;
1023
+ }
1024
+ /**
1025
+ * D-03: a MISSING marker is never a task's final verify. The executor has no MISSING
1026
+ * handling, so it would run the marker as a command ("command not found", or an
1027
+ * improvised check). Every marker is therefore a finding, and the detail names the
1028
+ * fix (WR-03):
1029
+ * - the file is created by an earlier task of the same plan or by the task itself:
1030
+ * it exists when this verify runs, so write its real test command
1031
+ * (`marker_not_replaced`);
1032
+ * - created by a later task, another plan or no task: move the scaffold before the
1033
+ * task (`later_task`, `other_plan:<id>`, `no_creator`); `no_path` names no file.
1034
+ */
1035
+ function missingFinding(task, pos, self, all) {
1036
+ const candidates = markerPaths(task.command ?? '');
1037
+ if (candidates.length === 0)
1038
+ return mk('scaffold_uncreated', 'no_path');
1039
+ for (const tok of candidates) {
1040
+ const key = normPath(tok);
1041
+ const at = self.firstPos.get(key);
1042
+ if (at !== undefined && at <= pos)
1043
+ continue;
1044
+ if (at !== undefined)
1045
+ return mk('scaffold_uncreated', 'later_task');
1046
+ const other = all.find((p) => p !== self && p.firstPos.has(key));
1047
+ if (other)
1048
+ return mk('scaffold_uncreated', `other_plan:${other.id}`);
1049
+ return mk('scaffold_uncreated', 'no_creator');
1050
+ }
1051
+ return mk('scaffold_uncreated', 'marker_not_replaced');
1052
+ }
1053
+ /** Characters that make a word something other than a plain relative path. */
1054
+ const NON_PATH_CHARS = [':', '*', '?', '[', ']', '{', '}', '$', '~', '<', '>', '|', '`', '"', "'"];
1055
+ /** Shell words that open a step but do not run anything themselves. */
1056
+ const STEP_OPENERS = new Set(['!', 'if', 'elif', 'while', 'until', 'then', 'do', 'else']);
1057
+ /** The directory of a leading `cd <dir>`, or null when its target cannot be followed. */
1058
+ function followableDir(word) {
1059
+ if (word === '' || word.startsWith('/') || word.startsWith('-'))
1060
+ return null;
1061
+ if (NON_PATH_CHARS.some((c) => word.includes(c)))
1062
+ return null;
1063
+ const dir = node_path_1.default.posix.normalize(normPath(word));
1064
+ if (dir === '..' || dir.startsWith('../') || dir.startsWith('/'))
1065
+ return null;
1066
+ return dir;
1067
+ }
1068
+ /** The verb of a stage: its first word, after any opener (`!`, `if`, `then`, ...) and subshell characters. */
1069
+ function stageVerb(rest) {
1070
+ const bare = (w) => {
1071
+ let i = 0;
1072
+ while (i < w.length && (w.charAt(i) === '(' || w.charAt(i) === '{'))
1073
+ i++;
1074
+ return w.slice(i);
1075
+ };
1076
+ let at = 0;
1077
+ while (at < rest.length - 1) {
1078
+ const w = bare(rest[at] ?? '');
1079
+ if (w !== '' && !STEP_OPENERS.has(w))
1080
+ break;
1081
+ at++;
1082
+ }
1083
+ return bare(rest[at] ?? '');
1084
+ }
1085
+ /** A package manager's own workspace options: `npm -w app`, `pnpm -F app`, `pnpm --filter app`. */
1086
+ const PM_WORKSPACE_OPTIONS = { npm: ['-w'], pnpm: ['-F', '--filter'] };
1087
+ /** Words that hand the rest of the step to another tool (`npx nx`, `pnpm exec turbo`, `yarn dlx lerna`). */
1088
+ const RUNNER_LAUNCHERS = new Set(['npx', 'pnpx', 'bunx', 'npm', 'pnpm', 'yarn', 'bun', 'exec', 'dlx']);
1089
+ /**
1090
+ * True when a step runs in a working directory the order check cannot follow (WR-04):
1091
+ * a child shell's `-c` script, a workspace runner or container (`ORDER_SKIP_RUNNERS`,
1092
+ * reached through `npx` / `pnpm exec` / `yarn dlx`), `yarn workspace`, `npm -w` or
1093
+ * `pnpm -F` / `--filter`. Only that step is skipped: it does not move the steps after it.
1094
+ */
1095
+ function stepRunsElsewhere(rest, verb) {
1096
+ const base = verb.slice(verb.lastIndexOf('/') + 1);
1097
+ if (/^(?:ba|z|da|k)?sh$/.test(base) && rest.some((w) => /^-[A-Za-z]*c$/.test(w)))
1098
+ return true;
1099
+ const own = PM_WORKSPACE_OPTIONS[base];
1100
+ if (own !== undefined && rest.some((w) => own.includes(w) || (w.startsWith('--') && own.some((o) => w.startsWith(`${o}=`))))) {
1101
+ return true;
1102
+ }
1103
+ let prev = '';
1104
+ for (const raw of rest) {
1105
+ if (raw.startsWith('-'))
1106
+ continue;
1107
+ const w = raw.replace(/^[({]+/, '');
1108
+ if (w === '' || STEP_OPENERS.has(w))
1109
+ continue;
1110
+ const name = w.slice(w.lastIndexOf('/') + 1);
1111
+ if (exports.ORDER_SKIP_RUNNERS.includes(name))
1112
+ return true;
1113
+ if (prev === 'yarn' && (name === 'workspace' || name === 'workspaces'))
1114
+ return true;
1115
+ if (!RUNNER_LAUNCHERS.has(name))
1116
+ return false;
1117
+ prev = name;
1118
+ }
1119
+ return false;
1120
+ }
1121
+ /** `sh -c`, `bash -lc`, `/bin/sh -c` ... in raw text, followed by the script argument. */
1122
+ const CHILD_SHELL_RAW = /(?:^|[\s;&|(/])(?:ba|z|da|k)?sh[ \t]+-[A-Za-z]*c(?=[ \t])/g;
1123
+ /**
1124
+ * `s` with the quoted script of every child shell (`bash -c "cd app && ..."`) emptied
1125
+ * to `""`, so its `&&` and `;` are not read as steps of this shell (WR-04). Null when
1126
+ * a script's quote never closes. One pass: each script is skipped once.
1127
+ */
1128
+ function dropShellScripts(s) {
1129
+ const re = new RegExp(CHILD_SHELL_RAW.source, 'g');
1130
+ const keep = [];
1131
+ let from = 0;
1132
+ for (let m = re.exec(s); m !== null; m = re.exec(s)) {
1133
+ let i = m.index + m[0].length;
1134
+ while (s.charAt(i) === ' ' || s.charAt(i) === '\t')
1135
+ i++;
1136
+ const q = s.charAt(i);
1137
+ if (q !== '"' && q !== "'")
1138
+ continue;
1139
+ let j = i + 1;
1140
+ while (j < s.length && s.charAt(j) !== q)
1141
+ j += q === '"' && s.charAt(j) === '\\' ? 2 : 1;
1142
+ if (j >= s.length)
1143
+ return null;
1144
+ keep.push(s.slice(from, i), q, q);
1145
+ from = j + 1;
1146
+ re.lastIndex = j + 1;
1147
+ }
1148
+ keep.push(s.slice(from));
1149
+ return keep.join('');
1150
+ }
1151
+ /** An option whose value is a file the command writes (`--test-reporter-destination`, `--outputFile`, `--junit-xml`). */
1152
+ const OUTPUT_OPTION = /destination|output|junit/i;
1153
+ /** A redirection operator that writes the next word: `>`, `>>`, `2>`, `&>`, `>|`. */
1154
+ const WRITE_REDIRECT = /^[0-9&]?>>?\|?$/;
1155
+ /**
1156
+ * The test files a real command runs, as project-relative paths, for the order check
1157
+ * (D-03). Null means the working directory cannot be followed and the command is
1158
+ * skipped. A leading `cd <dir> &&` is followed; any other `cd` or `pushd`, a
1159
+ * `CWD_OPTIONS` option and an `env -C` skip the command. A workspace runner, a
1160
+ * container or a child shell's script skips its own step (`stepRunsElsewhere`), and a
1161
+ * child shell's quoted script is never split into steps (`dropShellScripts`). Steps
1162
+ * whose verb is in `ORDER_SKIP_VERBS` name a path without running it, and the word
1163
+ * after `>` / `>>` or an output option (`OUTPUT_OPTION`) is written, not run (WR-04).
1164
+ * A word counts only when it is a
1165
+ * relative path with a directory and a test-file name (`TEST_FILE_PATTERNS`); a
1166
+ * glob, a variable, a `:` token, a bare name and a path outside the project are not
1167
+ * read. At most `MAX_ORDER_PATHS` paths come back. One pass over at most
1168
+ * `MAX_COMMAND_CHARS` characters, split on literal separators (T-38-25).
1169
+ */
1170
+ function commandTestPaths(command) {
1171
+ const text = dropShellScripts(commandText(command));
1172
+ if (text === null)
1173
+ return null;
1174
+ const parts = splitParts(text);
1175
+ let prefix = '';
1176
+ let from = 0;
1177
+ const lead = parts[0];
1178
+ if (lead !== undefined) {
1179
+ const words = collapse(lead.text).split(' ');
1180
+ if (words[0] === 'cd' && words.length === 2 && parts[1]?.sep === '&&') {
1181
+ const dir = followableDir(words[1] ?? '');
1182
+ if (dir === null)
1183
+ return null;
1184
+ prefix = dir;
1185
+ from = 1;
1186
+ }
1187
+ }
1188
+ const out = [];
1189
+ for (let pi = from; pi < parts.length; pi++) {
1190
+ for (const stage of pipeStages(parts[pi]?.text ?? '')) {
1191
+ const { rest, chdir } = stepWords(stage);
1192
+ const verb = stageVerb(rest);
1193
+ if (verb === 'cd' || verb === 'pushd')
1194
+ return null;
1195
+ if (exports.ORDER_SKIP_VERBS.includes(verb))
1196
+ continue;
1197
+ if (chdir)
1198
+ return null;
1199
+ if (stepRunsElsewhere(rest, verb))
1200
+ continue;
1201
+ for (const raw of rest) {
1202
+ for (const opt of exports.CWD_OPTIONS) {
1203
+ if (raw === opt || (opt.startsWith('--') && raw.startsWith(`${opt}=`)))
1204
+ return null;
1205
+ }
1206
+ if (raw.startsWith('-C') && !raw.startsWith('--') && raw.length > 2)
1207
+ return null;
1208
+ }
1209
+ let skipNext = false;
1210
+ for (const raw of rest) {
1211
+ // The word after `>` / `>>` or an output option is a file the command writes (WR-04).
1212
+ if (skipNext) {
1213
+ skipNext = false;
1214
+ continue;
1215
+ }
1216
+ if (WRITE_REDIRECT.test(raw)) {
1217
+ skipNext = true;
1218
+ continue;
1219
+ }
1220
+ let word = cleanToken(raw);
1221
+ if (word.startsWith('-')) {
1222
+ const eq = word.indexOf('=');
1223
+ if (OUTPUT_OPTION.test(eq === -1 ? word : word.slice(0, eq))) {
1224
+ skipNext = eq === -1;
1225
+ continue;
1226
+ }
1227
+ if (eq === -1)
1228
+ continue;
1229
+ word = cleanToken(word.slice(eq + 1));
1230
+ }
1231
+ const cut = word.indexOf('::');
1232
+ if (cut !== -1)
1233
+ word = word.slice(0, cut);
1234
+ if (word === '' || NON_PATH_CHARS.some((c) => word.includes(c)))
1235
+ continue;
1236
+ if (!word.includes('/') || word.startsWith('/'))
1237
+ continue;
1238
+ const clean = normPath(word);
1239
+ const joined = node_path_1.default.posix.normalize(prefix === '' ? clean : `${prefix}/${clean}`);
1240
+ if (joined === '..' || joined.startsWith('../') || joined.startsWith('/'))
1241
+ continue;
1242
+ if (!exports.TEST_FILE_PATTERNS.some((re) => re.test(joined)))
1243
+ continue;
1244
+ if (out.includes(joined))
1245
+ continue;
1246
+ out.push(joined);
1247
+ if (out.length >= exports.MAX_ORDER_PATHS)
1248
+ return out;
1249
+ }
1250
+ }
1251
+ }
1252
+ return out;
1253
+ }
1254
+ /**
1255
+ * D-03 for a real command: every test file it runs must exist when the task runs. A
1256
+ * path is fine when it is on disk, or listed by this task, an earlier task of this
1257
+ * plan, or a plan of an earlier wave. Otherwise: `later_task` (only a later task of
1258
+ * the plan lists it), `other_plan:<id>` (only a plan of the same or a later wave, or
1259
+ * of an unparsed wave) or `no_creator`. Returns the first finding. The earlier-wave
1260
+ * creator is accepted whatever it holds: the plan checker judges that (check 8d).
1261
+ */
1262
+ function orderFinding(task, pos, self, all, exists) {
1263
+ const found = commandTestPaths(task.command ?? '');
1264
+ if (found === null)
1265
+ return null;
1266
+ for (const file of found) {
1267
+ const at = self.firstPos.get(file);
1268
+ if (at !== undefined && at <= pos)
1269
+ continue;
1270
+ if (exists(file))
1271
+ continue;
1272
+ const others = all.filter((p) => p !== self && p.firstPos.has(file));
1273
+ // An earlier-wave creator runs before this plan starts, so it settles the file
1274
+ // before a later task of this plan can make it `later_task` (CR-02).
1275
+ const selfWave = self.wave;
1276
+ if (selfWave !== null && others.some((o) => o.wave !== null && o.wave < selfWave))
1277
+ continue;
1278
+ if (at !== undefined)
1279
+ return mk('scaffold_uncreated', 'later_task');
1280
+ const first = others[0];
1281
+ if (first === undefined)
1282
+ return mk('scaffold_uncreated', 'no_creator');
1283
+ return mk('scaffold_uncreated', `other_plan:${first.id}`);
1284
+ }
1285
+ return null;
1286
+ }
1287
+ /** Start offsets of every match of `re` in `s`, ascending. */
1288
+ function matchStarts(s, re) {
1289
+ const out = [];
1290
+ const g = new RegExp(re.source, `${re.flags}g`);
1291
+ for (let m = g.exec(s); m !== null; m = g.exec(s))
1292
+ out.push(m.index);
1293
+ return out;
1294
+ }
1295
+ /** End offsets of every match of `re` in `s`, ascending. */
1296
+ function matchEnds(s, re) {
1297
+ const out = [];
1298
+ const g = new RegExp(re.source, `${re.flags}g`);
1299
+ for (let m = g.exec(s); m !== null; m = g.exec(s))
1300
+ out.push(m.index + m[0].length);
1301
+ return out;
1302
+ }
1303
+ /** D-03: a task that writes a test file must not describe an empty, placeholder, todo, skipped or tautological test. */
1304
+ function scaffoldContentFinding(task) {
1305
+ if (!task.files.some((f) => isTestPath(f)))
1306
+ return null;
1307
+ // Sentence by sentence. A match is negated ("never a placeholder test or test.skip",
1308
+ // "write no placeholder tests") when a negation sits earlier in its own clause, or in
1309
+ // the three words right before the phrase. A negation in an earlier clause ("does not
1310
+ // crash, then add a placeholder test") does not reach it (WR-04). Negation and clause
1311
+ // positions are found once per sentence, so many matches stay linear (T-38-01).
1312
+ const sentences = task.action.split(/[.;!?](?=\s|$)|\n/).map(collapse).filter((s) => s !== '').map((s) => ({
1313
+ s, negations: matchStarts(s, SCAFFOLD_NEGATION), breaks: matchEnds(s, CLAUSE_BREAK),
1314
+ }));
1315
+ const affirmed = ({ s, negations, breaks }, at, phraseAt = at) => {
1316
+ const b = lowerBound(breaks, at + 1) - 1;
1317
+ const clauseStart = b >= 0 ? breaks[b] : 0;
1318
+ if (countBetween(negations, clauseStart - 1, at) > 0)
1319
+ return false;
1320
+ const lead = s.slice(at, phraseAt).split(' ').filter((w) => w !== '').slice(-3).join(' ');
1321
+ return !SCAFFOLD_NEGATION.test(lead);
1322
+ };
1323
+ for (const { kind, pattern } of exports.SCAFFOLD_VACUOUS_PATTERNS) {
1324
+ const every = new RegExp(pattern.source, `${pattern.flags}g`);
1325
+ for (const sentence of sentences) {
1326
+ every.lastIndex = 0;
1327
+ for (let m = every.exec(sentence.s); m !== null; m = every.exec(sentence.s)) {
1328
+ const phraseAt = m[1] === undefined ? m.index : m.index + m[0].length - m[1].length;
1329
+ if (kind === 'placeholder' && PLACEHOLDER_REMEDY.test(sentence.s.slice(m.index, phraseAt).trimEnd()))
1330
+ continue;
1331
+ if (affirmed(sentence, m.index, phraseAt))
1332
+ return mk('scaffold_empty', kind);
1333
+ }
1334
+ }
1335
+ }
1336
+ for (const literal of exports.TAUTOLOGY_LITERALS) {
1337
+ for (const sentence of sentences) {
1338
+ for (let at = sentence.s.indexOf(literal); at !== -1; at = sentence.s.indexOf(literal, at + literal.length)) {
1339
+ if (affirmed(sentence, at))
1340
+ return mk('scaffold_empty', 'tautology');
1341
+ }
1342
+ }
1343
+ }
1344
+ return null;
1345
+ }
1346
+ function taskFindings(task, pos, self, all, exists) {
1347
+ if (task.type.startsWith('checkpoint:'))
1348
+ return [];
1349
+ const findings = [];
1350
+ const behavior = checkBehavior(task.behavior);
1351
+ if (behavior)
1352
+ findings.push(behavior);
1353
+ if (task.command !== null) {
1354
+ if (task.command.length > exports.MAX_COMMAND_CHARS) {
1355
+ findings.push(mk('over_cap', 'command'));
1356
+ }
1357
+ else if (isMissingMarker(task.command)) {
1358
+ findings.push(missingFinding(task, pos, self, all));
1359
+ }
1360
+ else {
1361
+ const kind = classifyCommand(task.command);
1362
+ if (kind)
1363
+ findings.push(mk('verify_vacuous', kind));
1364
+ const order = exists === undefined ? null : orderFinding(task, pos, self, all, exists);
1365
+ if (order)
1366
+ findings.push(order);
1367
+ }
1368
+ }
1369
+ const scaffold = scaffoldContentFinding(task);
1370
+ if (scaffold)
1371
+ findings.push(scaffold);
1372
+ return findings;
1373
+ }
1374
+ /**
1375
+ * The verdict over already-read plans. `block` is `enforced && finding_count > 0`;
1376
+ * only plans without a SUMMARY count. `tdd_candidates_without_tdd` is information.
1377
+ * With `exists`, a test file a real command runs that is not on disk is also checked
1378
+ * against the plans that create it (`orderFinding`); without it the order check does
1379
+ * not run, so pure callers see only the text-level findings.
1380
+ */
1381
+ function analyzeTestSubstance(plans, enforced, exists) {
1382
+ const parsed = plans.map(parsePlan);
1383
+ const reports = [];
1384
+ const candidates = [];
1385
+ let findingCount = 0;
1386
+ for (const p of parsed) {
1387
+ const tasks = p.tasks.map((task, pos) => ({
1388
+ index: task.index, name: task.name, type: task.type, tdd: task.tdd,
1389
+ findings: taskFindings(task, pos, p, parsed, exists),
1390
+ }));
1391
+ if (!p.plan.executed) {
1392
+ findingCount += p.findings.length + tasks.reduce((n, t) => n + t.findings.length, 0);
1393
+ for (const task of p.tasks) {
1394
+ if (task.tdd || task.type.startsWith('checkpoint:'))
1395
+ continue;
1396
+ if (isBehaviorAddingTaskContent(task.snippet).checks.has_source_files) {
1397
+ candidates.push({ plan: p.id, index: task.index, name: task.name });
1398
+ }
1399
+ }
1400
+ }
1401
+ reports.push({ id: p.id, executed: p.plan.executed, findings: p.findings, tasks });
1402
+ }
1403
+ return {
1404
+ enforced,
1405
+ block: enforced && findingCount > 0,
1406
+ finding_count: findingCount,
1407
+ plans: reports,
1408
+ tdd_candidates_without_tdd: candidates,
1409
+ };
1410
+ }
1411
+ // ─── Disk and the phase-scoped check ──────────────────────────────────────────
1412
+ /**
1413
+ * Every `*-PLAN.md` of a phase directory, sorted, with the executed flag taken from
1414
+ * the matching `*-SUMMARY.md` (the rule `readPhasePlans` uses). A plan file that
1415
+ * cannot be read comes back with empty content, which `analyzeTestSubstance` reports
1416
+ * as `unreadable` / `read_error` rather than as a clean plan (T-38-04). Each entry is
1417
+ * `lstat`ed first (38-UF-1): one that is not a regular file (a symbolic link, FIFO,
1418
+ * directory, socket or device) is never opened, so a link cannot pull in text from outside
1419
+ * the phase and a FIFO cannot hang the check; a regular file over `MAX_PLAN_BYTES` is not
1420
+ * read either and comes back with `overCap: true`, which is reported as `over_cap` / `plan`
1421
+ * as it was when the file was read. `lstat` checks only the last path component, so a
1422
+ * symlinked `.planning` or phase directory (a worktree) still reads. Read-only.
1423
+ */
1424
+ function readPhaseSubstancePlans(phaseDirAbs) {
1425
+ let names;
1426
+ try {
1427
+ names = node_fs_1.default.readdirSync(phaseDirAbs);
1428
+ }
1429
+ catch {
1430
+ // An unreadable phase directory is `unreadable` / `read_error`, never a clean phase (WR-04).
1431
+ return [{ id: '(phase)', executed: false, content: '' }];
1432
+ }
1433
+ const planFiles = names.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
1434
+ const completed = new Set();
1435
+ for (const s of names) {
1436
+ if (!(s.endsWith('-SUMMARY.md') || s === 'SUMMARY.md'))
1437
+ continue;
1438
+ completed.add(s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''));
1439
+ completed.add(extractCanonicalPlanId(s));
1440
+ }
1441
+ const out = [];
1442
+ for (const file of planFiles) {
1443
+ const full = node_path_1.default.join(phaseDirAbs, file);
1444
+ let content = '';
1445
+ let overCap = false;
1446
+ try {
1447
+ const stat = node_fs_1.default.lstatSync(full);
1448
+ if (!stat.isFile())
1449
+ content = '';
1450
+ else if (stat.size > exports.MAX_PLAN_BYTES)
1451
+ overCap = true;
1452
+ else
1453
+ content = node_fs_1.default.readFileSync(full, 'utf8');
1454
+ }
1455
+ catch {
1456
+ content = '';
1457
+ }
1458
+ const id = file.replace('-PLAN.md', '').replace('PLAN.md', '');
1459
+ out.push({
1460
+ id,
1461
+ executed: completed.has(id) || completed.has(extractCanonicalPlanId(file)),
1462
+ content,
1463
+ ...(overCap ? { overCap: true } : {}),
1464
+ });
1465
+ }
1466
+ return out;
1467
+ }
1468
+ /** Symlinks followed for one lookup before it gives up (answers true, probes nothing more). */
1469
+ const MAX_LINK_HOPS = 40;
1470
+ const PATH_SPLIT = node_path_1.default.sep === '\\' ? /[\\/]+/ : /\/+/;
1471
+ /** True when `p` is `root` or below it. Both are absolute. */
1472
+ function isWithin(root, p) {
1473
+ const rel = node_path_1.default.relative(root, p);
1474
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(rel));
1475
+ }
1476
+ /**
1477
+ * One lookup of `projectPathExists`: walks `parts` from the real project root one
1478
+ * component at a time with `lstat`, which never follows the component it stats. A
1479
+ * symlink before the last component is read with `readlink` (which touches only the
1480
+ * link) and followed only while its target stays inside the project; a target outside
1481
+ * it, a link that cannot be read and a chain longer than MAX_LINK_HOPS answer true and
1482
+ * stop the walk, so nothing outside the project is ever statted (CR-01). The last
1483
+ * component is an entry: a symlink there is never followed.
1484
+ */
1485
+ function walkExists(root, parts) {
1486
+ let cur = root;
1487
+ const queue = parts.slice();
1488
+ let hops = 0;
1489
+ while (queue.length > 0) {
1490
+ const name = queue.shift();
1491
+ if (name === '' || name === '.')
1492
+ continue;
1493
+ if (name === '..') {
1494
+ cur = node_path_1.default.dirname(cur);
1495
+ if (!isWithin(root, cur))
1496
+ return true;
1497
+ continue;
1498
+ }
1499
+ const next = node_path_1.default.join(cur, name);
1500
+ let st;
1501
+ try {
1502
+ st = node_fs_1.default.lstatSync(next);
1503
+ }
1504
+ catch {
1505
+ return false;
1506
+ }
1507
+ if (queue.length === 0)
1508
+ return true;
1509
+ if (st.isSymbolicLink()) {
1510
+ hops++;
1511
+ if (hops > MAX_LINK_HOPS)
1512
+ return true;
1513
+ let target;
1514
+ try {
1515
+ target = node_fs_1.default.readlinkSync(next);
1516
+ }
1517
+ catch {
1518
+ return true;
1519
+ }
1520
+ if (node_path_1.default.isAbsolute(target)) {
1521
+ // An absolute target is placed lexically, so a `..` in it could hide a symlink: skip.
1522
+ if (target.split(PATH_SPLIT).includes('..') || !isWithin(root, node_path_1.default.resolve(target)))
1523
+ return true;
1524
+ queue.unshift(...node_path_1.default.relative(root, node_path_1.default.resolve(target)).split(node_path_1.default.sep));
1525
+ cur = root;
1526
+ }
1527
+ else {
1528
+ // `cur` is always a real directory, so the target's `..` steps are walked physically.
1529
+ queue.unshift(...target.split(PATH_SPLIT));
1530
+ }
1531
+ continue;
1532
+ }
1533
+ if (!st.isDirectory())
1534
+ return false;
1535
+ cur = next;
1536
+ }
1537
+ return true;
1538
+ }
1539
+ /**
1540
+ * Whether a project-relative path exists on disk, memoised. A path that resolves to
1541
+ * the project root or outside it answers true without touching the disk, so a token
1542
+ * from a plan can never make the verb probe the filesystem elsewhere. Inside the
1543
+ * project the path is walked component by component (`walkExists`): a symlinked
1544
+ * directory is followed only while it stays inside the project, and a symlink as the
1545
+ * last component is an entry that is never followed (T-38-24, CR-01).
1546
+ */
1547
+ function projectPathExists(projectDir) {
1548
+ const memo = new Map();
1549
+ let root = null;
1550
+ try {
1551
+ root = node_fs_1.default.realpathSync(projectDir);
1552
+ }
1553
+ catch {
1554
+ root = null;
1555
+ }
1556
+ return (relPath) => {
1557
+ const cached = memo.get(relPath);
1558
+ if (cached !== undefined)
1559
+ return cached;
1560
+ const abs = node_path_1.default.resolve(projectDir, relPath);
1561
+ const rel = node_path_1.default.relative(projectDir, abs);
1562
+ let answer;
1563
+ if (root === null || rel === '' || rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(rel)) {
1564
+ answer = true;
1565
+ }
1566
+ else {
1567
+ answer = walkExists(root, rel.split(node_path_1.default.sep));
1568
+ }
1569
+ memo.set(relPath, answer);
1570
+ return answer;
1571
+ };
1572
+ }
1573
+ /**
1574
+ * The phase-scoped check. The enforcement flag is `resolveGateConfig(projectDir).nyquist`,
1575
+ * the one reader the ceremony and the nyquist capability share, so only a real boolean
1576
+ * `false` turns it off (T-38-06). The phase directory comes from `resolvePhaseForRead`;
1577
+ * the raw argument is never joined into a path (T-38-02). An unknown phase blocks.
1578
+ * Nothing here looks for RESEARCH.md (D-05).
1579
+ */
1580
+ function computeTestSubstance(projectDir, phase) {
1581
+ // WR-04: ceremony.cjs is loaded here, not at module load, so a missing
1582
+ // ceremony-gates.json cannot take down every `check` verb.
1583
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1584
+ const { resolveGateConfig } = require('./ceremony.cjs');
1585
+ const enforced = resolveGateConfig(projectDir).nyquist;
1586
+ const result = resolvePhaseForRead(projectDir, phase);
1587
+ const relDir = result && typeof result['directory'] === 'string' ? result['directory'] : '';
1588
+ if (!relDir) {
1589
+ return { phase, phase_found: false, ...analyzeTestSubstance([], enforced), block: true };
1590
+ }
1591
+ const phaseDir = node_path_1.default.resolve(projectDir, relDir);
1592
+ const plans = readPhaseSubstancePlans(phaseDir);
1593
+ const report = analyzeTestSubstance(plans, enforced, projectPathExists(projectDir));
1594
+ if (plans.length === 0) {
1595
+ // A found phase with no PLAN.md has nothing to vouch for: a plan-level finding, never clean (WR-04).
1596
+ report.plans.push({ id: '(phase)', executed: false, findings: [mk('unreadable', 'no_plans')], tasks: [] });
1597
+ report.finding_count += 1;
1598
+ report.block = enforced;
1599
+ }
1600
+ return { phase, phase_found: true, ...report };
1601
+ }
1602
+ // ─── Test-file scanner (TEST-04, D-14, D-16) ───────────────────────────────────
1603
+ //
1604
+ // Validation-time half of the module: `scanTestSource` counts assertions per test
1605
+ // case in JS/TS, Python and Go text. Counting is deterministic; whether a project
1606
+ // helper asserts is the auditor's judgement, so a case with no recognised assertion
1607
+ // is reported and anything the scanner cannot read reports `unknown`, never `ok`
1608
+ // (T-38-14). Every pass is a single linear walk: literals are masked by one
1609
+ // character loop, brackets are paired by one stack pass, and per-case counts are
1610
+ // binary searches over sorted position arrays, so nested or unterminated input
1611
+ // cannot make the work quadratic (T-38-11). The scanner reads text only. The JS
1612
+ // tautology rules mirror eslint-rules/no-tautological-assert.cjs.
1613
+ /** A test file over this many characters is `unknown` / `over_cap`; `scanTestFile` checks `stat` before reading. */
1614
+ exports.MAX_SCAN_FILE_BYTES = 1048576;
1615
+ /** Files scanned per call; the rest report `unknown` / `over_file_cap`. */
1616
+ exports.MAX_SCAN_FILES = 200;
1617
+ /** Entries per list (`asserts_nothing`, `skipped`) in one file's report. */
1618
+ exports.MAX_LISTED_CASES = 20;
1619
+ exports.CASE_STATES = ['asserts', 'asserts_nothing', 'todo', 'skip'];
1620
+ exports.FILE_STATES = ['ok', 'no_cases', 'unknown'];
1621
+ exports.SCAN_UNKNOWN_REASONS = ['unsupported_language', 'over_cap', 'outside_project', 'not_found',
1622
+ 'not_regular_file', 'over_file_cap', 'unbalanced', 'unrecognised_shape', 'unreadable'];
1623
+ /** `test.<m>(` and `it.<m>(`. */
1624
+ exports.JS_CASE_MODIFIERS = ['only', 'skip', 'todo'];
1625
+ /** `test.<m>(` / `it.<m>(` members that produce cases the scanner cannot read: the file is `unknown`. */
1626
+ exports.JS_CASE_PRODUCING_MEMBERS = ['each', 'for', 'concurrent', 'sequential', 'runIf', 'skipIf',
1627
+ 'failing', 'fails'];
1628
+ /** `test.<m>(` / `it.<m>(` members that are hooks, steps or configuration, never a case (WR-01). */
1629
+ exports.JS_NON_CASE_MEMBERS = ['beforeEach', 'afterEach', 'beforeAll', 'afterAll', 'before', 'after',
1630
+ 'step', 'use', 'extend', 'info', 'setTimeout', 'slow', 'expect', 'mock'];
1631
+ /** Display tokens, one per recognised assertion form (the reference doc and its parity test use these). */
1632
+ exports.ASSERTION_VOCABULARY = {
1633
+ js: ['assert(', 'assert.*', 't.assert', 'expect(', '.should', 'assert*()', 'expect*()'],
1634
+ python: ['assert', 'self.assert*', 'pytest.raises', 'assert*()', 'expect*()'],
1635
+ go: ['t.Error*', 't.Fatal*', 't.Fail*', 'assert.*', 'require.*', 'assert*()', 'expect*()'],
1636
+ };
1637
+ /** A call or definition longer than this is never a literal tautology; its arguments are not parsed. */
1638
+ const MAX_TAUTOLOGY_SPAN = 2000;
1639
+ function languageOf(file) {
1640
+ const ext = node_path_1.default.extname(String(file)).toLowerCase();
1641
+ if (['.js', '.cjs', '.mjs', '.jsx', '.ts', '.cts', '.mts', '.tsx'].includes(ext))
1642
+ return 'js';
1643
+ if (ext === '.py')
1644
+ return 'python';
1645
+ if (ext === '.go')
1646
+ return 'go';
1647
+ return 'unknown';
1648
+ }
1649
+ // ─── Shared scanner helpers ───────────────────────────────────────────────────
1650
+ const CH_NL = 10;
1651
+ const CH_SPACE = 32;
1652
+ function toCodes(src) {
1653
+ const out = new Uint16Array(src.length);
1654
+ for (let i = 0; i < src.length; i++)
1655
+ out[i] = src.charCodeAt(i);
1656
+ return out;
1657
+ }
1658
+ function fromCodes(codes) {
1659
+ const parts = [];
1660
+ for (let i = 0; i < codes.length; i += 8192) {
1661
+ parts.push(String.fromCharCode.apply(null, codes.subarray(i, i + 8192)));
1662
+ }
1663
+ return parts.join('');
1664
+ }
1665
+ /** Blank `[from, to)` to spaces, newlines kept so line numbers survive. */
1666
+ function blankRange(codes, from, to) {
1667
+ const end = to > codes.length ? codes.length : to;
1668
+ for (let i = from < 0 ? 0 : from; i < end; i++)
1669
+ if (codes[i] !== CH_NL)
1670
+ codes[i] = CH_SPACE;
1671
+ }
1672
+ function isIdentCode(c) {
1673
+ return (c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 36 || c > 127;
1674
+ }
1675
+ function isIdentStartCode(c) {
1676
+ return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95 || c === 36 || c > 127;
1677
+ }
1678
+ function isSpaceCode(c) {
1679
+ return c === 32 || c === 9 || c === 10 || c === 13;
1680
+ }
1681
+ function skipSpaces(text, from) {
1682
+ let j = from;
1683
+ while (j < text.length && isSpaceCode(text.charCodeAt(j)))
1684
+ j++;
1685
+ return j;
1686
+ }
1687
+ /** True when the word `word` ends just before `at`, with only whitespace between. */
1688
+ function precededByWord(text, at, word) {
1689
+ let j = at - 1;
1690
+ while (j >= 0 && isSpaceCode(text.charCodeAt(j)))
1691
+ j--;
1692
+ const start = j - word.length + 1;
1693
+ if (start < 0 || text.slice(start, j + 1) !== word)
1694
+ return false;
1695
+ return start === 0 || !isIdentCode(text.charCodeAt(start - 1));
1696
+ }
1697
+ /** Positions of every `\n`, ascending. */
1698
+ function newlineIndexes(text) {
1699
+ const out = [];
1700
+ let from = 0;
1701
+ for (;;) {
1702
+ const i = text.indexOf('\n', from);
1703
+ if (i === -1)
1704
+ return out;
1705
+ out.push(i);
1706
+ from = i + 1;
1707
+ }
1708
+ }
1709
+ /** First index of `arr` whose value is >= x (arr ascending). */
1710
+ function lowerBound(arr, x) {
1711
+ let lo = 0;
1712
+ let hi = arr.length;
1713
+ while (lo < hi) {
1714
+ const mid = (lo + hi) >>> 1;
1715
+ if (arr[mid] < x)
1716
+ lo = mid + 1;
1717
+ else
1718
+ hi = mid;
1719
+ }
1720
+ return lo;
1721
+ }
1722
+ /** How many values of `arr` lie strictly between `lo` and `hi`. */
1723
+ function countBetween(arr, lo, hi) {
1724
+ const n = lowerBound(arr, hi) - lowerBound(arr, lo + 1);
1725
+ return n > 0 ? n : 0;
1726
+ }
1727
+ /** 1-based line of the offset `pos`. */
1728
+ function lineOf(newlines, pos) {
1729
+ return lowerBound(newlines, pos) + 1;
1730
+ }
1731
+ /** One stack pass: `pair[i]` is the index of the closer matching the opener at `i`, or -1. */
1732
+ function pairDelims(masked, open, close) {
1733
+ const pair = new Int32Array(masked.length).fill(-1);
1734
+ const stack = [];
1735
+ for (let i = 0; i < masked.length; i++) {
1736
+ const c = masked.charCodeAt(i);
1737
+ if (c === open)
1738
+ stack.push(i);
1739
+ else if (c === close && stack.length > 0)
1740
+ pair[stack.pop()] = i;
1741
+ }
1742
+ return pair;
1743
+ }
1744
+ /** Visit every identifier of a masked text. A number literal is skipped whole, so `1test` is not `test`. */
1745
+ function eachIdent(masked, visit) {
1746
+ const n = masked.length;
1747
+ let i = 0;
1748
+ while (i < n) {
1749
+ const c = masked.charCodeAt(i);
1750
+ if (isIdentStartCode(c)) {
1751
+ let j = i + 1;
1752
+ while (j < n && isIdentCode(masked.charCodeAt(j)))
1753
+ j++;
1754
+ visit(i, j, masked.slice(i, j));
1755
+ i = j;
1756
+ }
1757
+ else if (c >= 48 && c <= 57) {
1758
+ let j = i + 1;
1759
+ while (j < n && isIdentCode(masked.charCodeAt(j)))
1760
+ j++;
1761
+ i = j;
1762
+ }
1763
+ else {
1764
+ i++;
1765
+ }
1766
+ }
1767
+ }
1768
+ /** Top-level argument ranges (at most `max`) of the call whose parens enclose `[from, to)`. Brackets nest. */
1769
+ function splitTopArgs(masked, from, to, max) {
1770
+ const out = [];
1771
+ let depth = 0;
1772
+ let start = from;
1773
+ for (let i = from; i < to && out.length < max; i++) {
1774
+ const c = masked.charCodeAt(i);
1775
+ if (c === 40 || c === 91 || c === 123)
1776
+ depth++;
1777
+ else if (c === 41 || c === 93 || c === 125)
1778
+ depth--;
1779
+ else if (c === 44 && depth === 0) {
1780
+ out.push([start, i]);
1781
+ start = i + 1;
1782
+ }
1783
+ }
1784
+ if (out.length < max && (start < to || out.length > 0))
1785
+ out.push([start, to]);
1786
+ return out;
1787
+ }
1788
+ const NUMBER_LITERAL = /^(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+)$/;
1789
+ function isNumberLiteral(s) {
1790
+ return s.length <= 64 && NUMBER_LITERAL.test(s);
1791
+ }
1792
+ /** One quote-delimited literal: the masked text is quote, blanks, the same quote. */
1793
+ function isQuotedLiteral(masked, quotes = '\'"') {
1794
+ if (masked.length < 2)
1795
+ return false;
1796
+ const q = masked.charAt(0);
1797
+ if (!quotes.includes(q) || masked.charAt(masked.length - 1) !== q)
1798
+ return false;
1799
+ const inner = masked.slice(1, -1);
1800
+ return !inner.includes(q) && inner.trim() === '';
1801
+ }
1802
+ /** The original and masked text of one operand, both cut by the whitespace the MASKED text has at its ends. */
1803
+ function trimBoth(orig, mask) {
1804
+ let a = 0;
1805
+ let b = mask.length;
1806
+ while (a < b && isSpaceCode(mask.charCodeAt(a)))
1807
+ a++;
1808
+ while (b > a && isSpaceCode(mask.charCodeAt(b - 1)))
1809
+ b--;
1810
+ return [orig.slice(a, b), mask.slice(a, b)];
1811
+ }
1812
+ /** `m` starts with `open` and the matching closer is its last character. */
1813
+ function wholeDelimited(m, open, close) {
1814
+ if (m.length < 2 || m.charAt(0) !== open || m.charAt(m.length - 1) !== close)
1815
+ return false;
1816
+ let depth = 0;
1817
+ for (let i = 0; i < m.length; i++) {
1818
+ const ch = m.charAt(i);
1819
+ if (ch === open)
1820
+ depth++;
1821
+ else if (ch === close) {
1822
+ depth--;
1823
+ if (depth === 0)
1824
+ return i === m.length - 1;
1825
+ }
1826
+ }
1827
+ return false;
1828
+ }
1829
+ function emptyScan(language, state, reason) {
1830
+ return reason ? { language, state, reason, cases: [] } : { language, state, cases: [] };
1831
+ }
1832
+ /** The case name: the first argument's text, quotes stripped, sanitized and cut. */
1833
+ function caseName(masked, text, open, close) {
1834
+ const limit = Math.min(close, open + 1 + exports.MAX_QUOTED_CHARS * 4);
1835
+ const [first] = splitTopArgs(masked, open + 1, limit, 1);
1836
+ let raw = first ? text.slice(first[0], first[1]).trim() : '';
1837
+ const q = raw.charAt(0);
1838
+ if (q === '\'' || q === '"' || q === '`') {
1839
+ raw = raw.slice(1);
1840
+ if (raw.charAt(raw.length - 1) === q)
1841
+ raw = raw.slice(0, -1);
1842
+ }
1843
+ const name = quoted(raw);
1844
+ return name === '' ? '(unnamed)' : name;
1845
+ }
1846
+ /**
1847
+ * A project assertion helper: `assert*`, or `expect` followed by an upper-case
1848
+ * letter or `_` (`expectNoError`, `expect_called`). `expected*` and `expectation*`
1849
+ * are fixture builders, not assertions (WR-05).
1850
+ */
1851
+ function isAssertHelperName(w) {
1852
+ if (w.startsWith('assert'))
1853
+ return true;
1854
+ if (!w.startsWith('expect'))
1855
+ return false;
1856
+ const next = w.charCodeAt(6);
1857
+ return Number.isNaN(next) || (next >= 65 && next <= 90) || next === 95;
1858
+ }
1859
+ function caseState(modifier, assertions) {
1860
+ if (modifier === 'todo')
1861
+ return 'todo';
1862
+ if (modifier === 'skip')
1863
+ return 'skip';
1864
+ return assertions > 0 ? 'asserts' : 'asserts_nothing';
1865
+ }
1866
+ // ─── JS / TS ──────────────────────────────────────────────────────────────────
1867
+ // `<` is not a regex prefix: in a .jsx/.tsx file `</p>` is a closing tag, and reading
1868
+ // its `/` as a regex start masked the rest of the line, parens included (CR-02).
1869
+ const REGEX_PREFIX_CHARS = '(,=:[!&|?{};+-*%>~^';
1870
+ const REGEX_PREFIX_WORDS = new Set(['return', 'typeof', 'case', 'in', 'of', 'new', 'delete', 'void', 'throw']);
1871
+ /**
1872
+ * A string of the same length as `src` in which comments, string literals,
1873
+ * template-literal text and regex-literal bodies are spaces (newlines kept) and the
1874
+ * delimiters stay. `${...}` expressions inside a template are scanned as code. A
1875
+ * quote or a regex ends at its newline; a block comment or a template with no
1876
+ * closer masks to the end of input. One character loop, linear in the input.
1877
+ */
1878
+ function maskJs(src) {
1879
+ const n = src.length;
1880
+ const out = toCodes(src);
1881
+ const stack = [];
1882
+ let i = 0;
1883
+ let prevSig = -1;
1884
+ let wordStart = 0;
1885
+ const scanTemplate = (from) => {
1886
+ let j = from;
1887
+ while (j < n) {
1888
+ const c = src.charCodeAt(j);
1889
+ if (c === 92) {
1890
+ j += 2;
1891
+ continue;
1892
+ }
1893
+ if (c === 96) {
1894
+ blankRange(out, from, j);
1895
+ i = j + 1;
1896
+ prevSig = j;
1897
+ return;
1898
+ }
1899
+ if (c === 36 && src.charCodeAt(j + 1) === 123) {
1900
+ blankRange(out, from, j);
1901
+ stack.push(0);
1902
+ i = j + 2;
1903
+ prevSig = j + 1;
1904
+ return;
1905
+ }
1906
+ j++;
1907
+ }
1908
+ blankRange(out, from, n);
1909
+ i = n;
1910
+ };
1911
+ while (i < n) {
1912
+ const c = src.charCodeAt(i);
1913
+ if (c === 47) { // '/'
1914
+ const next = src.charCodeAt(i + 1);
1915
+ if (next === 47) {
1916
+ const e = src.indexOf('\n', i);
1917
+ const end = e === -1 ? n : e;
1918
+ blankRange(out, i, end);
1919
+ i = end;
1920
+ continue;
1921
+ }
1922
+ if (next === 42) {
1923
+ const e = src.indexOf('*/', i + 2);
1924
+ const end = e === -1 ? n : e + 2;
1925
+ blankRange(out, i, end);
1926
+ i = end;
1927
+ continue;
1928
+ }
1929
+ const prevCh = prevSig === -1 ? '' : src.charAt(prevSig);
1930
+ // `}` then `/>` closes a JSX self-closing tag after an expression or spread
1931
+ // attribute (`<Button onClick={fn} />`, `<Foo {...p} />`), never a regex (WR-06).
1932
+ // After `(`, `,` or `=` a `/>/` is still a regex (`s.split(/>/)`).
1933
+ const jsxSelfClose = next === 62 && prevCh === '}';
1934
+ const regexAllowed = !jsxSelfClose && (prevSig === -1
1935
+ || REGEX_PREFIX_CHARS.includes(prevCh)
1936
+ || (isIdentCode(src.charCodeAt(prevSig)) && REGEX_PREFIX_WORDS.has(src.slice(wordStart, prevSig + 1))));
1937
+ if (!regexAllowed) {
1938
+ prevSig = i;
1939
+ i++;
1940
+ continue;
1941
+ }
1942
+ let j = i + 1;
1943
+ let inClass = false;
1944
+ let closed = false;
1945
+ while (j < n) {
1946
+ const cj = src.charCodeAt(j);
1947
+ if (cj === 92) {
1948
+ j += 2;
1949
+ continue;
1950
+ }
1951
+ if (cj === CH_NL)
1952
+ break;
1953
+ if (inClass) {
1954
+ if (cj === 93)
1955
+ inClass = false;
1956
+ }
1957
+ else if (cj === 91) {
1958
+ inClass = true;
1959
+ }
1960
+ else if (cj === 47) {
1961
+ closed = true;
1962
+ break;
1963
+ }
1964
+ j++;
1965
+ }
1966
+ const stop = j > n ? n : j;
1967
+ blankRange(out, i + 1, stop);
1968
+ if (closed) {
1969
+ prevSig = stop;
1970
+ i = stop + 1;
1971
+ }
1972
+ else {
1973
+ prevSig = i;
1974
+ i = stop;
1975
+ }
1976
+ continue;
1977
+ }
1978
+ if (c === 39 || c === 34) { // ' "
1979
+ let j = i + 1;
1980
+ let closed = false;
1981
+ while (j < n) {
1982
+ const cj = src.charCodeAt(j);
1983
+ if (cj === 92) {
1984
+ j += 2;
1985
+ continue;
1986
+ }
1987
+ if (cj === c) {
1988
+ closed = true;
1989
+ break;
1990
+ }
1991
+ if (cj === CH_NL)
1992
+ break;
1993
+ j++;
1994
+ }
1995
+ const stop = j > n ? n : j;
1996
+ blankRange(out, i + 1, stop);
1997
+ if (closed) {
1998
+ prevSig = stop;
1999
+ i = stop + 1;
2000
+ }
2001
+ else {
2002
+ prevSig = i;
2003
+ i = stop;
2004
+ }
2005
+ continue;
2006
+ }
2007
+ if (c === 96) { // `
2008
+ scanTemplate(i + 1);
2009
+ continue;
2010
+ }
2011
+ if (c === 123) { // {
2012
+ if (stack.length > 0)
2013
+ stack[stack.length - 1]++;
2014
+ prevSig = i;
2015
+ i++;
2016
+ continue;
2017
+ }
2018
+ if (c === 125) { // }
2019
+ if (stack.length > 0) {
2020
+ if (stack[stack.length - 1] === 0) {
2021
+ stack.pop();
2022
+ scanTemplate(i + 1);
2023
+ continue;
2024
+ }
2025
+ stack[stack.length - 1]--;
2026
+ }
2027
+ prevSig = i;
2028
+ i++;
2029
+ continue;
2030
+ }
2031
+ if (isIdentCode(c)) {
2032
+ if (i === 0 || !isIdentCode(src.charCodeAt(i - 1)))
2033
+ wordStart = i;
2034
+ prevSig = i;
2035
+ i++;
2036
+ continue;
2037
+ }
2038
+ if (!isSpaceCode(c))
2039
+ prevSig = i;
2040
+ i++;
2041
+ }
2042
+ return fromCodes(out);
2043
+ }
2044
+ /** Positions of every `=>`. */
2045
+ function arrowPositions(masked) {
2046
+ const out = [];
2047
+ let from = 0;
2048
+ for (;;) {
2049
+ const i = masked.indexOf('=>', from);
2050
+ if (i === -1)
2051
+ return out;
2052
+ out.push(i);
2053
+ from = i + 2;
2054
+ }
2055
+ }
2056
+ /** `.ident(` chain after an `assert` identifier: the member names and the index of the call's open paren. */
2057
+ function readCallChain(masked, end) {
2058
+ let j = skipSpaces(masked, end);
2059
+ const methods = [];
2060
+ for (let hops = 0; hops < 6; hops++) {
2061
+ const c = masked.charCodeAt(j);
2062
+ if (c === 40)
2063
+ return { methods, open: j };
2064
+ if (c !== 46)
2065
+ return null;
2066
+ const k = skipSpaces(masked, j + 1);
2067
+ let m = k;
2068
+ while (m < masked.length && isIdentCode(masked.charCodeAt(m)))
2069
+ m++;
2070
+ if (m === k)
2071
+ return null;
2072
+ methods.push(masked.slice(k, m));
2073
+ j = skipSpaces(masked, m);
2074
+ }
2075
+ return null;
2076
+ }
2077
+ function endsWithOrTrue(m) {
2078
+ if (!m.endsWith('true'))
2079
+ return false;
2080
+ const before = m.slice(0, -4);
2081
+ if (before !== '' && isIdentCode(before.charCodeAt(before.length - 1)))
2082
+ return false;
2083
+ return before.trimEnd().endsWith('||');
2084
+ }
2085
+ function startsWithTrueOr(m) {
2086
+ if (!m.startsWith('true'))
2087
+ return false;
2088
+ const after = m.slice(4);
2089
+ if (after !== '' && isIdentCode(after.charCodeAt(0)))
2090
+ return false;
2091
+ return after.trimStart().startsWith('||');
2092
+ }
2093
+ /** An always-truthy literal, per the lint rule's category (a); `onlyTrue` is chai `isTrue`. */
2094
+ function jsTruthyLiteral(orig, mask, onlyTrue) {
2095
+ let [o, m] = trimBoth(orig, mask);
2096
+ if (m === 'true')
2097
+ return true;
2098
+ if (onlyTrue)
2099
+ return false;
2100
+ while (m.startsWith('!!')) {
2101
+ o = o.slice(2).trim();
2102
+ m = m.slice(2).trim();
2103
+ }
2104
+ if (m === 'true')
2105
+ return true;
2106
+ if (isNumberLiteral(o))
2107
+ return Number(o) !== 0;
2108
+ if (isQuotedLiteral(m))
2109
+ return o.length > 2;
2110
+ if (m.length >= 3 && /^\/ +\/[a-z]*$/.test(m))
2111
+ return true;
2112
+ if (wholeDelimited(m, '[', ']') || wholeDelimited(m, '{', '}'))
2113
+ return true;
2114
+ return endsWithOrTrue(m) || startsWithTrueOr(m);
2115
+ }
2116
+ /** A comparable key for a literal operand, per the lint rule's category (b); null when it is not a plain literal. */
2117
+ function jsLiteralKey(orig, mask) {
2118
+ const [o, m] = trimBoth(orig, mask);
2119
+ if (m === 'true' || m === 'false' || m === 'null')
2120
+ return `k:${m}`;
2121
+ if (isNumberLiteral(o))
2122
+ return `n:${Number(o)}`;
2123
+ if (isQuotedLiteral(m))
2124
+ return `s:${o.slice(1, -1)}`;
2125
+ if (m.length >= 2 && m.replace(/\s/g, '') === '[]')
2126
+ return '[]';
2127
+ if (m.length >= 2 && m.replace(/\s/g, '') === '{}')
2128
+ return '{}';
2129
+ return null;
2130
+ }
2131
+ const JS_EQUALITY_METHODS = new Set(['strictEqual', 'equal', 'deepEqual', 'deepStrictEqual']);
2132
+ /** True when the assertion call opening at `open` is a literal tautology (never fails). */
2133
+ function jsAssertCallIsTautology(masked, text, open, close, methods) {
2134
+ if (close < 0 || close - open > MAX_TAUTOLOGY_SPAN)
2135
+ return false;
2136
+ const last = methods.length > 0 ? methods[methods.length - 1] : '';
2137
+ const args = splitTopArgs(masked, open + 1, close, 2);
2138
+ if (last === '' || last === 'ok' || last === 'isTrue') {
2139
+ const a = args[0];
2140
+ return a !== undefined && jsTruthyLiteral(text.slice(a[0], a[1]), masked.slice(a[0], a[1]), last === 'isTrue');
2141
+ }
2142
+ if (JS_EQUALITY_METHODS.has(last)) {
2143
+ const a = args[0];
2144
+ const b = args[1];
2145
+ if (a === undefined || b === undefined)
2146
+ return false;
2147
+ const ka = jsLiteralKey(text.slice(a[0], a[1]), masked.slice(a[0], a[1]));
2148
+ const kb = jsLiteralKey(text.slice(b[0], b[1]), masked.slice(b[0], b[1]));
2149
+ return ka !== null && ka === kb;
2150
+ }
2151
+ return false;
2152
+ }
2153
+ /** `expect(X).toBe(Y)` / `.toEqual` / `.toStrictEqual` of identical literals, or `.toBeTruthy()` of a truthy literal. */
2154
+ function jsExpectIsTautology(masked, text, open, close, parenClose) {
2155
+ if (close < 0 || close - open > MAX_TAUTOLOGY_SPAN)
2156
+ return false;
2157
+ const k = skipSpaces(masked, close + 1);
2158
+ if (masked.charCodeAt(k) !== 46)
2159
+ return false;
2160
+ const n0 = skipSpaces(masked, k + 1);
2161
+ let n1 = n0;
2162
+ while (n1 < masked.length && isIdentCode(masked.charCodeAt(n1)))
2163
+ n1++;
2164
+ const matcher = masked.slice(n0, n1);
2165
+ const open2 = skipSpaces(masked, n1);
2166
+ if (masked.charCodeAt(open2) !== 40)
2167
+ return false;
2168
+ const close2 = parenClose[open2];
2169
+ if (close2 < 0 || close2 - open2 > MAX_TAUTOLOGY_SPAN)
2170
+ return false;
2171
+ const [x] = splitTopArgs(masked, open + 1, close, 1);
2172
+ if (x === undefined)
2173
+ return false;
2174
+ if (matcher === 'toBeTruthy') {
2175
+ return jsTruthyLiteral(text.slice(x[0], x[1]), masked.slice(x[0], x[1]), false);
2176
+ }
2177
+ if (matcher === 'toBe' || matcher === 'toEqual' || matcher === 'toStrictEqual') {
2178
+ const [y] = splitTopArgs(masked, open2 + 1, close2, 1);
2179
+ if (y === undefined)
2180
+ return false;
2181
+ const kx = jsLiteralKey(text.slice(x[0], x[1]), masked.slice(x[0], x[1]));
2182
+ const ky = jsLiteralKey(text.slice(y[0], y[1]), masked.slice(y[0], y[1]));
2183
+ return kx !== null && kx === ky;
2184
+ }
2185
+ return false;
2186
+ }
2187
+ /**
2188
+ * `skip` or `todo` from a node:test options object `{ skip: true }` / `{ todo: 'why' }`
2189
+ * passed as the case's second argument, or null. Only a literal truthy value counts;
2190
+ * a shorthand or an expression is a conditional skip and is left to the case's
2191
+ * assertions. The object is read only when it is at most MAX_TAUTOLOGY_SPAN long (WR-06).
2192
+ */
2193
+ function jsOptionsModifier(masked, text, open, close, braceClose) {
2194
+ const [, second] = splitTopArgs(masked, open + 1, Math.min(close, open + 1 + exports.MAX_QUOTED_CHARS * 4), 2);
2195
+ if (second === undefined)
2196
+ return null;
2197
+ const b = skipSpaces(masked, second[0]);
2198
+ if (masked.charCodeAt(b) !== 123)
2199
+ return null;
2200
+ const end = braceClose[b];
2201
+ if (end < 0 || end - b > MAX_TAUTOLOGY_SPAN)
2202
+ return null;
2203
+ for (const [x, y] of splitTopArgs(masked, b + 1, end, 50)) {
2204
+ const entry = masked.slice(x, y);
2205
+ const colon = entry.indexOf(':');
2206
+ if (colon === -1)
2207
+ continue;
2208
+ const key = entry.slice(0, colon).trim();
2209
+ if (key !== 'skip' && key !== 'todo')
2210
+ continue;
2211
+ if (jsTruthyLiteral(text.slice(x + colon + 1, y), entry.slice(colon + 1), false))
2212
+ return key;
2213
+ }
2214
+ return null;
2215
+ }
2216
+ /** The first parameter name of the callback whose `=>` or `function` is at `at`, or ''. */
2217
+ function jsCallbackParam(masked, at, isArrow, parenOpen) {
2218
+ if (isArrow) {
2219
+ let q = at - 1;
2220
+ while (q >= 0 && isSpaceCode(masked.charCodeAt(q)))
2221
+ q--;
2222
+ if (q < 0)
2223
+ return '';
2224
+ if (masked.charCodeAt(q) !== 41)
2225
+ return identBefore(masked, q + 1);
2226
+ const po = parenOpen[q];
2227
+ if (po < 0)
2228
+ return '';
2229
+ const k = skipSpaces(masked, po + 1);
2230
+ let m = k;
2231
+ while (m < q && isIdentCode(masked.charCodeAt(m)))
2232
+ m++;
2233
+ return masked.slice(k, m);
2234
+ }
2235
+ let j = skipSpaces(masked, at + 8);
2236
+ if (masked.charCodeAt(j) === 42)
2237
+ j = skipSpaces(masked, j + 1);
2238
+ while (j < masked.length && isIdentCode(masked.charCodeAt(j)))
2239
+ j++;
2240
+ j = skipSpaces(masked, j);
2241
+ if (masked.charCodeAt(j) !== 40)
2242
+ return '';
2243
+ const k = skipSpaces(masked, j + 1);
2244
+ let m = k;
2245
+ while (m < masked.length && isIdentCode(masked.charCodeAt(m)))
2246
+ m++;
2247
+ return masked.slice(k, m);
2248
+ }
2249
+ function scanJs(text) {
2250
+ const masked = maskJs(text);
2251
+ const parenClose = pairDelims(masked, 40, 41);
2252
+ const newlines = newlineIndexes(text);
2253
+ const arrows = arrowPositions(masked);
2254
+ const fnPos = [];
2255
+ const assertPos = [];
2256
+ const tautPos = [];
2257
+ const calls = [];
2258
+ /** `<receiver>.skip(` / `<receiver>.todo(` call positions, by receiver name (WR-06). */
2259
+ const memberMods = { skip: new Map(), todo: new Map() };
2260
+ let describeTokens = 0;
2261
+ let unrecognised = 0;
2262
+ /**
2263
+ * Open parens of calls that name a case the scanner cannot read (`Deno.test('a', …)`,
2264
+ * `test.fixme('a', …)`). Each counts as unrecognised only when a callback sits
2265
+ * inside its parens, decided after the walk: `re.test('x')` and `it.includes('a')`
2266
+ * carry none (WR-01).
2267
+ */
2268
+ const namedCallOpens = [];
2269
+ /** `(` then a quoted first argument right after the identifier ending at `e`: a named test call. */
2270
+ const opensNamedCall = (e) => {
2271
+ const j = skipSpaces(masked, e);
2272
+ if (masked.charCodeAt(j) !== 40)
2273
+ return false;
2274
+ const q = masked.charCodeAt(skipSpaces(masked, j + 1));
2275
+ return q === 39 || q === 34 || q === 96;
2276
+ };
2277
+ /** `test.<word>` / `it.<word>` that is not a case modifier; `e` ends the member name. */
2278
+ const otherMember = (word, e) => {
2279
+ const open = skipSpaces(masked, e);
2280
+ if (masked.charCodeAt(open) !== 40)
2281
+ return; // `it.status`: a variable named `it`, not a call
2282
+ if (word === 'describe') {
2283
+ describeTokens++;
2284
+ return;
2285
+ }
2286
+ if (exports.JS_NON_CASE_MEMBERS.includes(word))
2287
+ return;
2288
+ if (exports.JS_CASE_PRODUCING_MEMBERS.includes(word)) {
2289
+ unrecognised++;
2290
+ return;
2291
+ }
2292
+ if (opensNamedCall(e))
2293
+ namedCallOpens.push(open);
2294
+ };
2295
+ eachIdent(masked, (s, e, w) => {
2296
+ const prevCode = s > 0 ? masked.charCodeAt(s - 1) : 0;
2297
+ const afterDot = prevCode === 46;
2298
+ if (w === 'function') {
2299
+ fnPos.push(s);
2300
+ return;
2301
+ }
2302
+ if ((w === 'skip' || w === 'todo') && afterDot && masked.charCodeAt(skipSpaces(masked, e)) === 40) {
2303
+ const receiver = identBefore(masked, s - 1);
2304
+ if (receiver !== '' && receiver !== 'test' && receiver !== 'it' && receiver !== 'describe') {
2305
+ const byName = memberMods[w];
2306
+ const list = byName.get(receiver);
2307
+ if (list)
2308
+ list.push(s);
2309
+ else
2310
+ byName.set(receiver, [s]);
2311
+ }
2312
+ return;
2313
+ }
2314
+ if (w === 'test' || w === 'it' || w === 'describe') {
2315
+ const memberOfT = w === 'test' && afterDot && s >= 2 && masked.charAt(s - 2) === 't'
2316
+ && (s < 3 || (!isIdentCode(masked.charCodeAt(s - 3)) && masked.charCodeAt(s - 3) !== 46));
2317
+ const bare = !afterDot && !(s > 0 && isIdentCode(prevCode));
2318
+ if (bare || memberOfT) {
2319
+ let j = skipSpaces(masked, e);
2320
+ let modifier = null;
2321
+ if (masked.charCodeAt(j) === 46) {
2322
+ const k = skipSpaces(masked, j + 1);
2323
+ let m = k;
2324
+ while (m < masked.length && isIdentCode(masked.charCodeAt(m)))
2325
+ m++;
2326
+ const word = masked.slice(k, m);
2327
+ if (w !== 'describe' && exports.JS_CASE_MODIFIERS.includes(word)) {
2328
+ modifier = word;
2329
+ j = skipSpaces(masked, m);
2330
+ // `test.skip.each(rows)(…)`: a modifier on a table the scanner cannot read.
2331
+ if (masked.charCodeAt(j) === 46) {
2332
+ unrecognised++;
2333
+ return;
2334
+ }
2335
+ }
2336
+ else {
2337
+ if (w === 'describe')
2338
+ describeTokens++;
2339
+ else
2340
+ otherMember(word, m);
2341
+ return;
2342
+ }
2343
+ }
2344
+ if (masked.charCodeAt(j) === 40) {
2345
+ if (w === 'describe')
2346
+ describeTokens++;
2347
+ else
2348
+ calls.push({ at: s, open: j, modifier });
2349
+ }
2350
+ }
2351
+ else if (w !== 'describe' && afterDot && s >= 2 && isIdentCode(masked.charCodeAt(s - 2)) && opensNamedCall(e)) {
2352
+ // `Deno.test('a', () => …)`, `tap.test('a', (t) => …)`: a test file the scanner cannot
2353
+ // read, so `unknown`, never `no_cases` (WR-07), once a callback confirms it is a case.
2354
+ namedCallOpens.push(skipSpaces(masked, e));
2355
+ }
2356
+ return;
2357
+ }
2358
+ if ((w === 'specify' || w === 'context') && !afterDot && !(s > 0 && isIdentCode(prevCode)) && opensNamedCall(e)) {
2359
+ unrecognised++;
2360
+ return;
2361
+ }
2362
+ if (w === 'assert') {
2363
+ const chain = readCallChain(masked, e);
2364
+ if (chain === null)
2365
+ return;
2366
+ const close = parenClose[chain.open];
2367
+ if (jsAssertCallIsTautology(masked, text, chain.open, close, chain.methods))
2368
+ tautPos.push(s);
2369
+ else
2370
+ assertPos.push(s);
2371
+ return;
2372
+ }
2373
+ if (w === 'expect') {
2374
+ const j = skipSpaces(masked, e);
2375
+ if (masked.charCodeAt(j) !== 40)
2376
+ return;
2377
+ // A bare `expect(x);` has no matcher and asserts nothing (WR-05).
2378
+ const close = parenClose[j];
2379
+ if (close >= 0 && masked.charCodeAt(skipSpaces(masked, close + 1)) !== 46)
2380
+ return;
2381
+ if (jsExpectIsTautology(masked, text, j, close, parenClose))
2382
+ tautPos.push(s);
2383
+ else
2384
+ assertPos.push(s);
2385
+ return;
2386
+ }
2387
+ if (w === 'should') {
2388
+ if (afterDot)
2389
+ assertPos.push(s);
2390
+ return;
2391
+ }
2392
+ if (isAssertHelperName(w)) {
2393
+ const j = skipSpaces(masked, e);
2394
+ if (masked.charCodeAt(j) === 40 && !precededByWord(masked, s, 'function'))
2395
+ assertPos.push(s);
2396
+ }
2397
+ });
2398
+ for (const open of namedCallOpens) {
2399
+ const close = parenClose[open];
2400
+ if (close < 0 || countBetween(arrows, open, close) > 0 || countBetween(fnPos, open, close) > 0)
2401
+ unrecognised++;
2402
+ }
2403
+ const braceClose = pairDelims(masked, 123, 125);
2404
+ const parenOpen = new Int32Array(masked.length).fill(-1);
2405
+ for (let i = 0; i < parenClose.length; i++)
2406
+ if (parenClose[i] >= 0)
2407
+ parenOpen[parenClose[i]] = i;
2408
+ /**
2409
+ * The callback parameter, memoised by the callback's position. Nested cases share
2410
+ * their innermost callback, so without the memo each case re-walks the same
2411
+ * whitespace run around `=>` or `function` and the scan turns quadratic (T-38-11).
2412
+ */
2413
+ const paramCache = new Map();
2414
+ const paramAt = (at, isArrow) => {
2415
+ let p = paramCache.get(at);
2416
+ if (p === undefined) {
2417
+ p = jsCallbackParam(masked, at, isArrow, parenOpen);
2418
+ paramCache.set(at, p);
2419
+ }
2420
+ return p;
2421
+ };
2422
+ /** `skip`/`todo` from a `t.skip()` / `t.todo()` call (or mocha `this.skip()`) on the case's own callback parameter. */
2423
+ const bodyModifier = (open, close) => {
2424
+ const ai = lowerBound(arrows, open + 1);
2425
+ const fi = lowerBound(fnPos, open + 1);
2426
+ const arrow = ai < arrows.length && arrows[ai] < close ? arrows[ai] : -1;
2427
+ const fn = fi < fnPos.length && fnPos[fi] < close ? fnPos[fi] : -1;
2428
+ if (arrow === -1 && fn === -1)
2429
+ return null;
2430
+ const isArrow = fn === -1 || (arrow !== -1 && arrow < fn);
2431
+ const names = [paramAt(isArrow ? arrow : fn, isArrow)];
2432
+ if (!isArrow)
2433
+ names.push('this');
2434
+ for (const mod of ['skip', 'todo']) {
2435
+ for (const name of names) {
2436
+ const list = name === '' ? undefined : memberMods[mod].get(name);
2437
+ if (list && countBetween(list, open, close) > 0)
2438
+ return mod;
2439
+ }
2440
+ }
2441
+ return null;
2442
+ };
2443
+ const cases = [];
2444
+ let unbalanced = 0;
2445
+ let noCallback = 0;
2446
+ for (const call of calls) {
2447
+ const close = parenClose[call.open];
2448
+ if (close < 0) {
2449
+ unbalanced++;
2450
+ continue;
2451
+ }
2452
+ const hasCallback = countBetween(arrows, call.open, close) > 0 || countBetween(fnPos, call.open, close) > 0;
2453
+ if (call.modifier !== 'todo' && !hasCallback) {
2454
+ noCallback++;
2455
+ continue;
2456
+ }
2457
+ const assertions = countBetween(assertPos, call.open, close);
2458
+ const modifier = call.modifier === 'skip' || call.modifier === 'todo' ? call.modifier
2459
+ : (jsOptionsModifier(masked, text, call.open, close, braceClose) ?? bodyModifier(call.open, close) ?? call.modifier);
2460
+ cases.push({
2461
+ name: caseName(masked, text, call.open, close),
2462
+ line: lineOf(newlines, call.at),
2463
+ assertions,
2464
+ tautologies: countBetween(tautPos, call.open, close),
2465
+ state: caseState(modifier, assertions),
2466
+ });
2467
+ }
2468
+ // A case the scanner could not read never disappears behind the ones it could:
2469
+ // any dropped case makes the file `unknown`, with the readable cases still listed (T-38-14).
2470
+ const dropped = unbalanced + noCallback + unrecognised;
2471
+ if (cases.length > 0 && dropped === 0)
2472
+ return { language: 'js', state: 'ok', cases };
2473
+ if (cases.length > 0) {
2474
+ return { language: 'js', state: 'unknown', reason: unrecognised + noCallback === 0 ? 'unbalanced' : 'unrecognised_shape', cases };
2475
+ }
2476
+ if (calls.length === 0 && describeTokens === 0 && unrecognised === 0)
2477
+ return emptyScan('js', 'no_cases');
2478
+ const shapeProblems = unrecognised + noCallback + describeTokens;
2479
+ return emptyScan('js', 'unknown', shapeProblems === 0 && unbalanced > 0 ? 'unbalanced' : 'unrecognised_shape');
2480
+ }
2481
+ // ─── Python ───────────────────────────────────────────────────────────────────
2482
+ /**
2483
+ * Python text with `#` comments and string contents (single, double and triple
2484
+ * quoted, escapes honoured) blanked, delimiters and newlines kept. A one-line
2485
+ * string ends at its newline; a triple-quoted string with no closer masks to the
2486
+ * end of input. A prefix letter (`r`, `b`, `f`, `u`) is ordinary code.
2487
+ */
2488
+ function maskPython(src) {
2489
+ const n = src.length;
2490
+ const out = toCodes(src);
2491
+ let i = 0;
2492
+ while (i < n) {
2493
+ const c = src.charCodeAt(i);
2494
+ if (c === 35) {
2495
+ const e = src.indexOf('\n', i);
2496
+ const end = e === -1 ? n : e;
2497
+ blankRange(out, i, end);
2498
+ i = end;
2499
+ continue;
2500
+ }
2501
+ if (c === 39 || c === 34) {
2502
+ if (src.charCodeAt(i + 1) === c && src.charCodeAt(i + 2) === c) {
2503
+ let j = i + 3;
2504
+ let closed = false;
2505
+ while (j < n) {
2506
+ const cj = src.charCodeAt(j);
2507
+ if (cj === 92) {
2508
+ j += 2;
2509
+ continue;
2510
+ }
2511
+ if (cj === c && src.charCodeAt(j + 1) === c && src.charCodeAt(j + 2) === c) {
2512
+ closed = true;
2513
+ break;
2514
+ }
2515
+ j++;
2516
+ }
2517
+ const stop = j > n ? n : j;
2518
+ blankRange(out, i + 3, stop);
2519
+ i = closed ? stop + 3 : n;
2520
+ continue;
2521
+ }
2522
+ let j = i + 1;
2523
+ let closed = false;
2524
+ while (j < n) {
2525
+ const cj = src.charCodeAt(j);
2526
+ if (cj === 92) {
2527
+ j += 2;
2528
+ continue;
2529
+ }
2530
+ if (cj === c) {
2531
+ closed = true;
2532
+ break;
2533
+ }
2534
+ if (cj === CH_NL)
2535
+ break;
2536
+ j++;
2537
+ }
2538
+ const stop = j > n ? n : j;
2539
+ blankRange(out, i + 1, stop);
2540
+ i = closed ? stop + 1 : stop;
2541
+ continue;
2542
+ }
2543
+ i++;
2544
+ }
2545
+ return fromCodes(out);
2546
+ }
2547
+ /** `[a, b)` of the masked text with its end whitespace removed. */
2548
+ function trimRange(masked, a, b) {
2549
+ let lo = a;
2550
+ let hi = b;
2551
+ while (lo < hi && isSpaceCode(masked.charCodeAt(lo)))
2552
+ lo++;
2553
+ while (hi > lo && isSpaceCode(masked.charCodeAt(hi - 1)))
2554
+ hi--;
2555
+ return [lo, hi];
2556
+ }
2557
+ /** A string prefix (`r`, `b`, `u`, `f`, two at most) in front of a quote is cut from both texts. */
2558
+ function stripPyPrefix(orig, mask) {
2559
+ let k = 0;
2560
+ while (k < 2 && k < mask.length && 'rRbBuUfF'.includes(mask.charAt(k)))
2561
+ k++;
2562
+ const q = mask.charAt(k);
2563
+ if (k > 0 && (q === '"' || q === '\''))
2564
+ return [orig.slice(k), mask.slice(k)];
2565
+ return [orig, mask];
2566
+ }
2567
+ function unwrapParens(orig, mask) {
2568
+ let [o, m] = trimBoth(orig, mask);
2569
+ for (let depth = 0; depth < 3 && wholeDelimited(m, '(', ')'); depth++) {
2570
+ [o, m] = trimBoth(o.slice(1, -1), m.slice(1, -1));
2571
+ }
2572
+ return [o, m];
2573
+ }
2574
+ function isWordBefore(text, end, word) {
2575
+ if (end < word.length || text.slice(end - word.length, end) !== word)
2576
+ return false;
2577
+ return end === word.length || !isIdentCode(text.charCodeAt(end - word.length - 1));
2578
+ }
2579
+ function pyOrTrue(m) {
2580
+ if (m.endsWith('True') && (m.length === 4 || !isIdentCode(m.charCodeAt(m.length - 5)))) {
2581
+ const before = m.slice(0, -4).trimEnd();
2582
+ if (isWordBefore(before, before.length, 'or'))
2583
+ return true;
2584
+ }
2585
+ if (m.startsWith('True') && (m.length === 4 || !isIdentCode(m.charCodeAt(4)))) {
2586
+ const after = m.slice(4).trimStart();
2587
+ return after.startsWith('or') && (after.length === 2 || !isIdentCode(after.charCodeAt(2)));
2588
+ }
2589
+ return false;
2590
+ }
2591
+ /** `True`, a non-zero number, a non-empty string, or an expression with an `or True` side. */
2592
+ function pyTruthyLiteral(orig, mask) {
2593
+ const [o, m] = unwrapParens(orig, mask);
2594
+ if (m === 'True')
2595
+ return true;
2596
+ if (isNumberLiteral(o))
2597
+ return Number(o) !== 0;
2598
+ const [o2, m2] = stripPyPrefix(o, m);
2599
+ if (isQuotedLiteral(m2))
2600
+ return o2.length > 2;
2601
+ return pyOrTrue(m);
2602
+ }
2603
+ function pyLiteralKey(orig, mask) {
2604
+ const [o, m] = unwrapParens(orig, mask);
2605
+ if (m === 'True' || m === 'False' || m === 'None')
2606
+ return `k:${m}`;
2607
+ if (isNumberLiteral(o))
2608
+ return `n:${Number(o)}`;
2609
+ const [o2, m2] = stripPyPrefix(o, m);
2610
+ if (isQuotedLiteral(m2))
2611
+ return `s:${o2.slice(1, -1)}`;
2612
+ return null;
2613
+ }
2614
+ const PY_EQUALITY_METHODS = new Set(['assertEqual', 'assertEquals', 'assertIs']);
2615
+ const PY_SKIP_MARKERS = ['mark.skip', 'unittest.skip', 'skipUnless', 'skipIf'];
2616
+ /** True when the unittest call `assertTrue(...)` / `assertEqual(...)` opening at `open` is a literal tautology. */
2617
+ function pyCallIsTautology(masked, text, word, open, close) {
2618
+ if (close < 0 || close - open > MAX_TAUTOLOGY_SPAN)
2619
+ return false;
2620
+ if (word === 'assertTrue') {
2621
+ const [a] = splitTopArgs(masked, open + 1, close, 1);
2622
+ return a !== undefined && pyTruthyLiteral(text.slice(a[0], a[1]), masked.slice(a[0], a[1]));
2623
+ }
2624
+ if (PY_EQUALITY_METHODS.has(word)) {
2625
+ const args = splitTopArgs(masked, open + 1, close, 2);
2626
+ const a = args[0];
2627
+ const b = args[1];
2628
+ if (a === undefined || b === undefined)
2629
+ return false;
2630
+ const ka = pyLiteralKey(text.slice(a[0], a[1]), masked.slice(a[0], a[1]));
2631
+ const kb = pyLiteralKey(text.slice(b[0], b[1]), masked.slice(b[0], b[1]));
2632
+ return ka !== null && ka === kb;
2633
+ }
2634
+ return false;
2635
+ }
2636
+ /** `assert <expr>`: the first top-level operand of the statement, as `[start, end)`, or null when empty or too long. */
2637
+ function pyStatementOperand(masked, end) {
2638
+ const nl = masked.indexOf('\n', end);
2639
+ const eol = nl === -1 ? masked.length : nl;
2640
+ const [a, b] = trimRange(masked, end, eol);
2641
+ if (a >= b || b - a > MAX_TAUTOLOGY_SPAN)
2642
+ return null;
2643
+ const [first] = splitTopArgs(masked, a, b, 1);
2644
+ return first ?? null;
2645
+ }
2646
+ function scanPython(text) {
2647
+ const masked = maskPython(text);
2648
+ const n = masked.length;
2649
+ const newlines = newlineIndexes(text);
2650
+ const lineCount = newlines.length + 1;
2651
+ const starts = new Array(lineCount);
2652
+ starts[0] = 0;
2653
+ for (let k = 1; k < lineCount; k++)
2654
+ starts[k] = newlines[k - 1] + 1;
2655
+ const lineEnd = (k) => (k < newlines.length ? newlines[k] : n);
2656
+ const indent = new Int32Array(lineCount);
2657
+ const blank = new Uint8Array(lineCount);
2658
+ const found = [];
2659
+ const ends = [];
2660
+ const stack = [];
2661
+ let sawClassTest = false;
2662
+ for (let k = 0; k < lineCount; k++) {
2663
+ const end = lineEnd(k);
2664
+ let p = starts[k];
2665
+ while (p < end && (masked.charCodeAt(p) === 32 || masked.charCodeAt(p) === 9))
2666
+ p++;
2667
+ indent[k] = p - starts[k];
2668
+ let onlySpace = true;
2669
+ for (let q = p; q < end; q++) {
2670
+ if (!isSpaceCode(masked.charCodeAt(q))) {
2671
+ onlySpace = false;
2672
+ break;
2673
+ }
2674
+ }
2675
+ if (onlySpace) {
2676
+ blank[k] = 1;
2677
+ continue;
2678
+ }
2679
+ while (stack.length > 0 && found[stack[stack.length - 1]].indent >= indent[k]) {
2680
+ ends[stack.pop()] = k;
2681
+ }
2682
+ if (masked.startsWith('class', p) && (masked.charCodeAt(p + 5) === 32 || masked.charCodeAt(p + 5) === 9)) {
2683
+ const q = skipSpaces(masked, p + 5);
2684
+ if (masked.startsWith('Test', q))
2685
+ sawClassTest = true;
2686
+ continue;
2687
+ }
2688
+ if (masked.startsWith('async', p) && (masked.charCodeAt(p + 5) === 32 || masked.charCodeAt(p + 5) === 9)) {
2689
+ p = skipSpaces(masked, p + 5);
2690
+ }
2691
+ if (!masked.startsWith('def', p) || (masked.charCodeAt(p + 3) !== 32 && masked.charCodeAt(p + 3) !== 9))
2692
+ continue;
2693
+ let q = skipSpaces(masked, p + 3);
2694
+ const nameStart = q;
2695
+ while (q < end && isIdentCode(masked.charCodeAt(q)))
2696
+ q++;
2697
+ if (!masked.startsWith('test', nameStart))
2698
+ continue;
2699
+ let r = q;
2700
+ while (r < end && (masked.charCodeAt(r) === 32 || masked.charCodeAt(r) === 9))
2701
+ r++;
2702
+ if (masked.charCodeAt(r) !== 40)
2703
+ continue;
2704
+ stack.push(found.length);
2705
+ found.push({ name: masked.slice(nameStart, q), defLine: k, nameEnd: q, indent: indent[k] });
2706
+ }
2707
+ while (stack.length > 0)
2708
+ ends[stack.pop()] = lineCount;
2709
+ const parenClose = pairDelims(masked, 40, 41);
2710
+ const assertPos = [];
2711
+ const tautPos = [];
2712
+ eachIdent(masked, (s, e, w) => {
2713
+ const prev = s > 0 ? masked.charCodeAt(s - 1) : 0;
2714
+ if (w === 'assert') {
2715
+ let b = s - 1;
2716
+ while (b >= 0 && (masked.charCodeAt(b) === 32 || masked.charCodeAt(b) === 9))
2717
+ b--;
2718
+ if (prev !== 46 && (b < 0 || masked.charCodeAt(b) === CH_NL)) {
2719
+ const operand = pyStatementOperand(masked, e);
2720
+ if (operand !== null
2721
+ && pyTruthyLiteral(text.slice(operand[0], operand[1]), masked.slice(operand[0], operand[1])))
2722
+ tautPos.push(s);
2723
+ else
2724
+ assertPos.push(s);
2725
+ return;
2726
+ }
2727
+ }
2728
+ if (w === 'raises') {
2729
+ const j = skipSpaces(masked, e);
2730
+ if (masked.charCodeAt(j) === 40 && s >= 7 && masked.slice(s - 7, s) === 'pytest.')
2731
+ assertPos.push(s);
2732
+ return;
2733
+ }
2734
+ if (isAssertHelperName(w)) {
2735
+ const j = skipSpaces(masked, e);
2736
+ if (masked.charCodeAt(j) !== 40 || precededByWord(masked, s, 'def'))
2737
+ return;
2738
+ if (pyCallIsTautology(masked, text, w, j, parenClose[j]))
2739
+ tautPos.push(s);
2740
+ else
2741
+ assertPos.push(s);
2742
+ }
2743
+ });
2744
+ const cases = [];
2745
+ found.forEach((c, idx) => {
2746
+ const lo = c.nameEnd;
2747
+ const endLine = ends[idx];
2748
+ const hi = endLine >= lineCount ? n : starts[endLine];
2749
+ const assertions = countBetween(assertPos, lo, hi);
2750
+ let skipped = false;
2751
+ for (let k = c.defLine - 1; k >= 0 && !skipped && blank[k] === 0; k--) {
2752
+ const at = starts[k] + indent[k];
2753
+ if (masked.charCodeAt(at) !== 64)
2754
+ break;
2755
+ const deco = masked.slice(at, lineEnd(k));
2756
+ skipped = PY_SKIP_MARKERS.some((marker) => deco.includes(marker));
2757
+ }
2758
+ cases.push({
2759
+ name: quoted(c.name),
2760
+ line: c.defLine + 1,
2761
+ assertions,
2762
+ tautologies: countBetween(tautPos, lo, hi),
2763
+ state: caseState(skipped ? 'skip' : null, assertions),
2764
+ });
2765
+ });
2766
+ if (cases.length > 0)
2767
+ return { language: 'python', state: 'ok', cases };
2768
+ if (!sawClassTest)
2769
+ return emptyScan('python', 'no_cases');
2770
+ return emptyScan('python', 'unknown', 'unrecognised_shape');
2771
+ }
2772
+ // ─── Go ───────────────────────────────────────────────────────────────────────
2773
+ /**
2774
+ * Go text with `//` and block comments blanked and the contents of double-quoted
2775
+ * strings, rune literals and backtick raw strings blanked; delimiters and newlines
2776
+ * kept. A quote or rune ends at its newline; a block comment or raw string with no
2777
+ * closer masks to the end of input.
2778
+ */
2779
+ function maskGo(src) {
2780
+ const n = src.length;
2781
+ const out = toCodes(src);
2782
+ let i = 0;
2783
+ while (i < n) {
2784
+ const c = src.charCodeAt(i);
2785
+ if (c === 47) {
2786
+ const next = src.charCodeAt(i + 1);
2787
+ if (next === 47) {
2788
+ const e = src.indexOf('\n', i);
2789
+ const end = e === -1 ? n : e;
2790
+ blankRange(out, i, end);
2791
+ i = end;
2792
+ continue;
2793
+ }
2794
+ if (next === 42) {
2795
+ const e = src.indexOf('*/', i + 2);
2796
+ const end = e === -1 ? n : e + 2;
2797
+ blankRange(out, i, end);
2798
+ i = end;
2799
+ continue;
2800
+ }
2801
+ i++;
2802
+ continue;
2803
+ }
2804
+ if (c === 34 || c === 39) {
2805
+ let j = i + 1;
2806
+ let closed = false;
2807
+ while (j < n) {
2808
+ const cj = src.charCodeAt(j);
2809
+ if (cj === 92) {
2810
+ j += 2;
2811
+ continue;
2812
+ }
2813
+ if (cj === c) {
2814
+ closed = true;
2815
+ break;
2816
+ }
2817
+ if (cj === CH_NL)
2818
+ break;
2819
+ j++;
2820
+ }
2821
+ const stop = j > n ? n : j;
2822
+ blankRange(out, i + 1, stop);
2823
+ i = closed ? stop + 1 : stop;
2824
+ continue;
2825
+ }
2826
+ if (c === 96) {
2827
+ const e = src.indexOf('`', i + 1);
2828
+ const stop = e === -1 ? n : e;
2829
+ blankRange(out, i + 1, stop);
2830
+ i = e === -1 ? n : e + 1;
2831
+ continue;
2832
+ }
2833
+ i++;
2834
+ }
2835
+ return fromCodes(out);
2836
+ }
2837
+ function goLiteralKey(orig, mask) {
2838
+ const [o, m] = trimBoth(orig, mask);
2839
+ if (m === 'true' || m === 'false' || m === 'nil')
2840
+ return `k:${m}`;
2841
+ if (isNumberLiteral(o))
2842
+ return `n:${Number(o)}`;
2843
+ if (isQuotedLiteral(m, '"`\''))
2844
+ return `s:${o.slice(1, -1)}`;
2845
+ return null;
2846
+ }
2847
+ const GO_RECEIVER_ASSERTIONS = new Set(['Error', 'Errorf', 'Fatal', 'Fatalf', 'Fail', 'FailNow']);
2848
+ const GO_RECEIVER_SKIPS = new Set(['Skip', 'Skipf', 'SkipNow']);
2849
+ const GO_EQUALITY_METHODS = new Set(['Equal', 'EqualValues']);
2850
+ /** `.Name(` after the identifier ending at `end`: the member name and its open paren, or null. */
2851
+ function readMemberCall(masked, end) {
2852
+ const j = skipSpaces(masked, end);
2853
+ if (masked.charCodeAt(j) !== 46)
2854
+ return null;
2855
+ const k = skipSpaces(masked, j + 1);
2856
+ let m = k;
2857
+ while (m < masked.length && isIdentCode(masked.charCodeAt(m)))
2858
+ m++;
2859
+ if (m === k)
2860
+ return null;
2861
+ const open = skipSpaces(masked, m);
2862
+ if (masked.charCodeAt(open) !== 40)
2863
+ return null;
2864
+ return { name: masked.slice(k, m), open };
2865
+ }
2866
+ /** The identifier that ends just before `at` (whitespace skipped), or ''. */
2867
+ function identBefore(masked, at) {
2868
+ let j = at - 1;
2869
+ while (j >= 0 && isSpaceCode(masked.charCodeAt(j)))
2870
+ j--;
2871
+ const end = j + 1;
2872
+ while (j >= 0 && isIdentCode(masked.charCodeAt(j)))
2873
+ j--;
2874
+ return masked.slice(j + 1, end);
2875
+ }
2876
+ /** Testify `assert.X(t, ...)` / `require.X(t, ...)`: True(t, true) and Equal(t, lit, same lit) pass whatever the code does. */
2877
+ function goTestifyIsTautology(masked, text, name, open, close) {
2878
+ if (close < 0 || close - open > MAX_TAUTOLOGY_SPAN)
2879
+ return false;
2880
+ const args = splitTopArgs(masked, open + 1, close, 3);
2881
+ if (name === 'True') {
2882
+ const a = args[1];
2883
+ return a !== undefined && trimBoth(text.slice(a[0], a[1]), masked.slice(a[0], a[1]))[1] === 'true';
2884
+ }
2885
+ if (GO_EQUALITY_METHODS.has(name)) {
2886
+ const a = args[1];
2887
+ const b = args[2];
2888
+ if (a === undefined || b === undefined)
2889
+ return false;
2890
+ const ka = goLiteralKey(text.slice(a[0], a[1]), masked.slice(a[0], a[1]));
2891
+ const kb = goLiteralKey(text.slice(b[0], b[1]), masked.slice(b[0], b[1]));
2892
+ return ka !== null && ka === kb;
2893
+ }
2894
+ return false;
2895
+ }
2896
+ /** The receiver name of `(<name> *testing.T)` parameters, or null for any other signature. */
2897
+ function goTestReceiver(masked, open, close) {
2898
+ if (close < 0 || close - open > 300)
2899
+ return null;
2900
+ const inner = masked.slice(open + 1, close);
2901
+ const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\*\s*testing\.T\s*$/.exec(inner);
2902
+ return m ? (m[1] ?? null) : null;
2903
+ }
2904
+ function scanGo(text) {
2905
+ const masked = maskGo(text);
2906
+ const n = masked.length;
2907
+ const newlines = newlineIndexes(text);
2908
+ const braceClose = pairDelims(masked, 123, 125);
2909
+ const parenClose = pairDelims(masked, 40, 41);
2910
+ const receivers = new Set();
2911
+ const testifyObjects = new Set();
2912
+ const found = [];
2913
+ let testTokens = 0;
2914
+ let unbalanced = 0;
2915
+ eachIdent(masked, (s, e, w) => {
2916
+ if (w === 'testing') {
2917
+ const j = skipSpaces(masked, e);
2918
+ if (masked.charCodeAt(j) === 46) {
2919
+ const k = skipSpaces(masked, j + 1);
2920
+ let m = k;
2921
+ while (m < n && isIdentCode(masked.charCodeAt(m)))
2922
+ m++;
2923
+ const kind = masked.slice(k, m);
2924
+ if (kind === 'T' || kind === 'TB' || kind === 'B') {
2925
+ let b = s - 1;
2926
+ while (b >= 0 && isSpaceCode(masked.charCodeAt(b)))
2927
+ b--;
2928
+ if (b >= 0 && masked.charCodeAt(b) === 42)
2929
+ b--;
2930
+ const name = identBefore(masked, b + 1);
2931
+ if (name !== '')
2932
+ receivers.add(name);
2933
+ }
2934
+ }
2935
+ return;
2936
+ }
2937
+ if (w === 'assert' || w === 'require') {
2938
+ const call = readMemberCall(masked, e);
2939
+ if (call !== null && call.name === 'New') {
2940
+ let b = s - 1;
2941
+ while (b >= 0 && isSpaceCode(masked.charCodeAt(b)))
2942
+ b--;
2943
+ if (b >= 0 && masked.charCodeAt(b) === 61) {
2944
+ b--;
2945
+ if (b >= 0 && masked.charCodeAt(b) === 58)
2946
+ b--;
2947
+ const name = identBefore(masked, b + 1);
2948
+ if (name !== '')
2949
+ testifyObjects.add(name);
2950
+ }
2951
+ }
2952
+ return;
2953
+ }
2954
+ if (w !== 'func')
2955
+ return;
2956
+ let j = skipSpaces(masked, e);
2957
+ if (masked.charCodeAt(j) === 40) {
2958
+ const pc = parenClose[j];
2959
+ if (pc < 0)
2960
+ return;
2961
+ j = skipSpaces(masked, pc + 1);
2962
+ let m = j;
2963
+ while (m < n && isIdentCode(masked.charCodeAt(m)))
2964
+ m++;
2965
+ const method = masked.slice(j, m);
2966
+ if (method.startsWith('Test') && method !== 'TestMain' && masked.charCodeAt(skipSpaces(masked, m)) === 40)
2967
+ testTokens++;
2968
+ return;
2969
+ }
2970
+ let m = j;
2971
+ while (m < n && isIdentCode(masked.charCodeAt(m)))
2972
+ m++;
2973
+ const fn = masked.slice(j, m);
2974
+ if (!fn.startsWith('Test') || fn === 'TestMain')
2975
+ return;
2976
+ const open = skipSpaces(masked, m);
2977
+ if (masked.charCodeAt(open) !== 40)
2978
+ return;
2979
+ testTokens++;
2980
+ const recv = goTestReceiver(masked, open, parenClose[open]);
2981
+ if (recv === null)
2982
+ return;
2983
+ const brace = skipSpaces(masked, parenClose[open] + 1);
2984
+ if (masked.charCodeAt(brace) !== 123)
2985
+ return;
2986
+ if (braceClose[brace] < 0) {
2987
+ unbalanced++;
2988
+ return;
2989
+ }
2990
+ receivers.add(recv);
2991
+ found.push({ name: fn, at: j, open: brace });
2992
+ });
2993
+ const assertPos = [];
2994
+ const tautPos = [];
2995
+ const skipPos = [];
2996
+ eachIdent(masked, (s, e, w) => {
2997
+ const afterDot = s > 0 && masked.charCodeAt(s - 1) === 46;
2998
+ if (receivers.has(w) && !afterDot) {
2999
+ const call = readMemberCall(masked, e);
3000
+ if (call === null)
3001
+ return;
3002
+ if (GO_RECEIVER_ASSERTIONS.has(call.name))
3003
+ assertPos.push(s);
3004
+ else if (GO_RECEIVER_SKIPS.has(call.name))
3005
+ skipPos.push(s);
3006
+ return;
3007
+ }
3008
+ if ((w === 'assert' || w === 'require') && !afterDot) {
3009
+ const call = readMemberCall(masked, e);
3010
+ if (call !== null) {
3011
+ if (call.name === 'New')
3012
+ return;
3013
+ if (goTestifyIsTautology(masked, text, call.name, call.open, parenClose[call.open]))
3014
+ tautPos.push(s);
3015
+ else
3016
+ assertPos.push(s);
3017
+ return;
3018
+ }
3019
+ }
3020
+ if (testifyObjects.has(w) && !afterDot) {
3021
+ if (readMemberCall(masked, e) !== null)
3022
+ assertPos.push(s);
3023
+ return;
3024
+ }
3025
+ if (isAssertHelperName(w)) {
3026
+ const j = skipSpaces(masked, e);
3027
+ if (masked.charCodeAt(j) === 40 && !precededByWord(masked, s, 'func'))
3028
+ assertPos.push(s);
3029
+ }
3030
+ });
3031
+ const cases = found.map((c) => {
3032
+ const close = braceClose[c.open];
3033
+ const assertions = countBetween(assertPos, c.open, close);
3034
+ return {
3035
+ name: quoted(c.name),
3036
+ line: lineOf(newlines, c.at),
3037
+ assertions,
3038
+ tautologies: countBetween(tautPos, c.open, close),
3039
+ state: caseState(countBetween(skipPos, c.open, close) > 0 ? 'skip' : null, assertions),
3040
+ };
3041
+ });
3042
+ // Every `Test*` token that did not become a case (a suite method, another
3043
+ // signature, an unpaired brace) makes the file `unknown`, cases still listed (T-38-14).
3044
+ const dropped = testTokens - found.length;
3045
+ if (cases.length > 0 && dropped === 0)
3046
+ return { language: 'go', state: 'ok', cases };
3047
+ if (cases.length > 0) {
3048
+ return { language: 'go', state: 'unknown', reason: unbalanced === dropped ? 'unbalanced' : 'unrecognised_shape', cases };
3049
+ }
3050
+ if (testTokens === 0)
3051
+ return emptyScan('go', 'no_cases');
3052
+ return emptyScan('go', 'unknown', unbalanced > 0 && unbalanced === testTokens ? 'unbalanced' : 'unrecognised_shape');
3053
+ }
3054
+ // ─── Dispatch ─────────────────────────────────────────────────────────────────
3055
+ /**
3056
+ * Per-case assertion counts of one test file's text. Over MAX_SCAN_FILE_BYTES
3057
+ * characters, another language, or a scanner failure is `unknown`; a file with no
3058
+ * test case token is `no_cases`; `ok` only when at least one case was found.
3059
+ */
3060
+ function scanTestSource(text, language) {
3061
+ if (language !== 'js' && language !== 'python' && language !== 'go') {
3062
+ return emptyScan('unknown', 'unknown', 'unsupported_language');
3063
+ }
3064
+ if (typeof text !== 'string')
3065
+ return emptyScan(language, 'unknown', 'unreadable');
3066
+ if (text.length > exports.MAX_SCAN_FILE_BYTES)
3067
+ return emptyScan(language, 'unknown', 'over_cap');
3068
+ try {
3069
+ if (language === 'js')
3070
+ return scanJs(text);
3071
+ if (language === 'python')
3072
+ return scanPython(text);
3073
+ return scanGo(text);
3074
+ }
3075
+ catch {
3076
+ return emptyScan(language, 'unknown', 'unrecognised_shape');
3077
+ }
3078
+ }
3079
+ /** A file path echoed to an agent: posix separators, sanitized and cut. */
3080
+ function displayPath(p) {
3081
+ return quoted(p.split('\\').join('/'), 200);
3082
+ }
3083
+ function unknownFile(file, language, reason) {
3084
+ return {
3085
+ file, language, state: 'unknown', reason, cases_total: 0, asserting: 0, asserts_nothing_total: 0,
3086
+ skip_total: 0, asserts_nothing: [], skipped: [], truncated: false,
3087
+ };
3088
+ }
3089
+ function toFileScan(file, scan) {
3090
+ const nothing = scan.cases.filter((c) => c.state === 'asserts_nothing' || c.state === 'todo');
3091
+ const skipped = scan.cases.filter((c) => c.state === 'skip');
3092
+ const out = {
3093
+ file,
3094
+ language: scan.language,
3095
+ state: scan.state,
3096
+ cases_total: scan.cases.length,
3097
+ asserting: scan.cases.filter((c) => c.state === 'asserts').length,
3098
+ asserts_nothing_total: nothing.length,
3099
+ skip_total: skipped.length,
3100
+ asserts_nothing: nothing.slice(0, exports.MAX_LISTED_CASES).map((c) => ({
3101
+ name: c.name, line: c.line, state: c.state === 'todo' ? 'todo' : 'asserts_nothing',
3102
+ })),
3103
+ skipped: skipped.slice(0, exports.MAX_LISTED_CASES).map((c) => ({ name: c.name, line: c.line })),
3104
+ truncated: nothing.length > exports.MAX_LISTED_CASES || skipped.length > exports.MAX_LISTED_CASES,
3105
+ };
3106
+ if (scan.reason)
3107
+ out.reason = scan.reason;
3108
+ return out;
3109
+ }
3110
+ /** True when a relative path leaves its root (`..`, `../x`) or is on another drive. */
3111
+ function leavesRoot(rel) {
3112
+ return rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(rel);
3113
+ }
3114
+ function errorCode(err) {
3115
+ return err !== null && typeof err === 'object' && 'code' in err ? String(err.code) : '';
3116
+ }
3117
+ /**
3118
+ * Scan one test file for the project at `projectDir`. The path is resolved against
3119
+ * the project root and must stay inside it twice: lexically, then after
3120
+ * `fs.realpathSync` of both sides so a symlink cannot escape (T-38-10). It must be a
3121
+ * regular file within MAX_SCAN_FILE_BYTES, checked by `stat` before any read
3122
+ * (T-38-11). The file is read as text and never executed (T-38-13).
3123
+ */
3124
+ function scanTestFile(projectDir, file) {
3125
+ const raw = String(file);
3126
+ const language = languageOf(raw);
3127
+ const root = node_path_1.default.resolve(projectDir);
3128
+ const abs = node_path_1.default.resolve(root, raw);
3129
+ const rel = node_path_1.default.relative(root, abs);
3130
+ const shown = displayPath(rel);
3131
+ if (leavesRoot(rel))
3132
+ return unknownFile(shown, language, 'outside_project');
3133
+ let real;
3134
+ let realRoot;
3135
+ try {
3136
+ realRoot = node_fs_1.default.realpathSync(root);
3137
+ real = node_fs_1.default.realpathSync(abs);
3138
+ }
3139
+ catch (err) {
3140
+ const code = errorCode(err);
3141
+ return unknownFile(shown, language, code === 'ENOENT' || code === 'ENOTDIR' ? 'not_found' : 'unreadable');
3142
+ }
3143
+ if (leavesRoot(node_path_1.default.relative(realRoot, real)))
3144
+ return unknownFile(shown, language, 'outside_project');
3145
+ let size = 0;
3146
+ try {
3147
+ const stat = node_fs_1.default.statSync(real);
3148
+ if (!stat.isFile())
3149
+ return unknownFile(shown, language, 'not_regular_file');
3150
+ size = stat.size;
3151
+ }
3152
+ catch {
3153
+ return unknownFile(shown, language, 'unreadable');
3154
+ }
3155
+ if (size > exports.MAX_SCAN_FILE_BYTES)
3156
+ return unknownFile(shown, language, 'over_cap');
3157
+ if (language === 'unknown')
3158
+ return unknownFile(shown, language, 'unsupported_language');
3159
+ let text;
3160
+ try {
3161
+ text = node_fs_1.default.readFileSync(real, 'utf8');
3162
+ }
3163
+ catch {
3164
+ return unknownFile(shown, language, 'unreadable');
3165
+ }
3166
+ return toFileScan(shown, scanTestSource(text, language));
3167
+ }
3168
+ /**
3169
+ * Scan several test files. The first MAX_SCAN_FILES are scanned; the rest report
3170
+ * `unknown` / `over_file_cap`. The totals are exact; the per-file lists are capped.
3171
+ */
3172
+ function scanTestFiles(projectDir, files) {
3173
+ const list = Array.isArray(files) ? files : [];
3174
+ const scanned = list.map((f, i) => (i < exports.MAX_SCAN_FILES
3175
+ ? scanTestFile(projectDir, f)
3176
+ : unknownFile(displayPath(String(f)), languageOf(String(f)), 'over_file_cap')));
3177
+ const sum = (pick) => scanned.reduce((n, f) => n + pick(f), 0);
3178
+ return {
3179
+ mode: 'scan',
3180
+ files: scanned,
3181
+ totals: {
3182
+ files: scanned.length,
3183
+ cases: sum((f) => f.cases_total),
3184
+ asserts_nothing: sum((f) => f.asserts_nothing_total),
3185
+ skipped: sum((f) => f.skip_total),
3186
+ unknown_files: sum((f) => (f.state === 'unknown' ? 1 : 0)),
3187
+ no_case_files: sum((f) => (f.state === 'no_cases' ? 1 : 0)),
3188
+ },
3189
+ };
3190
+ }