diff --git a/.changeset/gallant-jaguars-wander.md b/.changeset/gallant-jaguars-wander.md new file mode 100644 index 000000000..babf3c731 --- /dev/null +++ b/.changeset/gallant-jaguars-wander.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3965 +--- +**Two lint rules that could not reach the code they govern now do, and `quick-tasks-append` stops overwriting curated progress values.** `local/no-adhoc-markdown-parsing` self-gated on its own filename, so it silently skipped every `.cts` file in a `src/` subdirectory and could not be widened by configuration alone; it now also covers `tests/` and `scripts/`, and the 80 hand-rolled markdown parses it surfaced are routed through the existing sectionizer and table seams — including one test that asserted against the wrong table column and so could never fail. `local/no-adhoc-regex-escape` examined only bare identifiers, missing the property-access shape runtime data actually arrives in, which is why it never caught a known ReDoS. Separately, `quick-tasks-append` gained optional `--quick-id`/`--slug`/`--directory` so a caller with a real quick task emits the canonical row, and a body-only append no longer forces a re-derive of disk-derived progress frontmatter that replaced curated values. (#3951) diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 307a2cc58..228b037a1 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -989,6 +989,15 @@ node gsd-tools.cjs verify-path-exists # Append a row to STATE.md's "Quick Tasks Completed" table (schema-backed; #2133) node gsd-tools.cjs quick-tasks-append --task "" +# Optional (#3356) — supply a real quick id and task directory to write the canonical row the +# `/gsd-quick` workflow itself renders, instead of a positional `#` and an em-dash `Directory`: +node gsd-tools.cjs quick-tasks-append --task "" --quick-id --slug +node gsd-tools.cjs quick-tasks-append --task "" --directory "[-](./quick/-/)" +# All three flags are optional. Omit them (as `fast.md` does, having neither an id nor a task +# directory) and the emitted row is byte-identical to the pre-#3356 behavior. `--directory` wins +# outright when given; otherwise `--quick-id` + `--slug` together derive the permalink. +# This append touches only the body table — it no longer forces a re-derive of the disk-derived +# `progress.*` frontmatter, which previously overwrote curated values (#3356). # See "Milestone Commands" below for `milestone archive-quick` (#2142) — sweeps .planning/quick/* into # milestones/-quick/ and clears this table, without a full `milestone complete`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d531815ed..eda90198b 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -201,6 +201,7 @@ - [No Silent Swallow, No Verdict From Dropped Data](#3885-no-silent-swallow-no-verdict-from-dropped-data) - [Runtime Marker Resolution, Derived Codex Sandbox, and In-Phase Short-Form Dependencies](#3897-runtime-marker-resolution-derived-codex-sandbox-and-in-phase-short-form-dependencies) - [Hooks Declare Their Crash Policy](#3911-hooks-declare-their-crash-policy) + - [Reachable Lint Rules and a Non-Destructive Quick-Task Append](#3951-reachable-lint-rules-and-a-non-destructive-quick-task-append) --- @@ -3976,6 +3977,56 @@ this vocabulary is layered over. --- +### 3951. Reachable Lint Rules and a Non-Destructive Quick-Task Append + +**Purpose:** Make two ESLint rules cover the code they were written to govern, and stop +`quick-tasks-append` from overwriting curated `progress.*` values on a body-only write. + +**What changed:** + +- **`local/no-adhoc-markdown-parsing` reaches its whole registered surface.** The rule short-circuited + unless a file's path matched a flat `src/*.cts` pattern, so it **self-gated on its own filename**. + Two consequences: 28 `.cts` files in `src/` subdirectories sat inside the `src/**/*.cts` glob it was + registered on and were silently skipped, and the rule could not be extended by configuration at all — + widening the glob alone left it inert. Both halves now move together, and a test pins that the gate + and the registration agree in *both* directions. +- **The rule now also covers `tests/**` and `scripts/**`**, which surfaced **80 hand-rolled markdown + parses across 43 test files**. Seventy are routed through the existing `markdown-sectionizer` and + `markdown-table` seams; ten are suppressed with a stated reason (six of those are a shell-pipe + detector whose regex merely resembles a table). +- **`local/no-adhoc-regex-escape` sees property access.** Its unsafe-`new RegExp` arm examined only + bare identifiers, so `new RegExp(obj['key'])` — the shape runtime data actually arrives in — was + invisible. That is why it never fired on a known ReDoS. It now inspects `MemberExpression`, with an + exemption keyed strictly on the property being `source` (18 safe sites), plus provenance exemptions + for `_SOURCE` constants reached through a required module (3 sites). +- **`quick-tasks-append` can write the canonical row.** Optional `--quick-id`, `--slug` and + `--directory` let a caller that has a real quick task emit the same row `/gsd-quick` renders. Omit + them — as `fast.md` does, having neither an id nor a directory — and the row is byte-identical to + before. +- **A body-only append no longer re-derives progress.** The route was the only body-only STATE.md + writer not passing `{ resync: false }`, so appending one row triggered a full re-derive of the + disk-derived `progress.*` frontmatter and replaced curated values. Reproduced: a project with two + real phase directories and a curated `total_phases: 25` collapsed to `2` on append. + +**Found by the widening:** `tests/config-field-docs.test.cjs` asserted that +`workflow.subagent_timeout`'s documented default is not `600` — but read the **Type** column instead +of **Default**, so it compared `'number'` against `'600'` and could never fail. The guard against +regressing to the old seconds-based default had been inert. It is now row-scoped and real. + +**Known limits:** + +- #3426 and #3239 are **not** closed by this. Their hand-rolled scans in + `tests/package-legitimacy-gate.test.cjs` are built from line filters and `split('|')`, not the + regex-literal fingerprints this rule detects — measured at zero violations even with the gate + bypassed. They need new detectors, which is a separate design. +- The 10 suppressions are suppressions, not fixes. Each names why the raw markdown text is the + subject of that assertion. +- The `src/` subdirectory hole was **latent** — zero violations existed there when it was fixed. It is + closed because "no violations today" is not a property that keeps holding, not because it was + hiding anything. + +--- + _Generated by `scripts/gen-features.cjs` — add a fragment under `docs/features/` and run `--write`._ diff --git a/docs/adr/3473-enforcement-by-construction.md b/docs/adr/3473-enforcement-by-construction.md index 96580a283..ad97e8f47 100644 --- a/docs/adr/3473-enforcement-by-construction.md +++ b/docs/adr/3473-enforcement-by-construction.md @@ -478,10 +478,10 @@ Net across the set: one guard retired, one increase recorded honestly. The incre | `scripts/lint-state-field-drift.cjs` | **RETAINED** — the Phase-3 retirement instruction rested on a wrong premise about what this guard does; see §8.8's amendment. It guards the ADR-3180 §7.7 / #3187 coercion ladder, which no schema makes unrepresentable. | | `scripts/lint-vendored-deps.cjs` | **not reusable as-is** — generalized to a manifest by §8.1; see the correction below | | `local/no-external-require-in-bin` | reused as-is; enforces §8.1's packaging rule | -| `local/no-adhoc-markdown-parsing` | widened past `src/**/*.cts` per Decision 5 (coverage fix, tracked on #3426/#3239) | -| `local/no-adhoc-regex-escape` | widened to `MemberExpression`/`TSAsExpression` with a `.source`-aware exemption (§8.3) | +| `local/no-adhoc-markdown-parsing` | **DONE (#3951)** — self-gating filename check fixed (it made the glob widening inert) and reach extended to `tests/**`/`scripts/**`; 80 violations resolved. **#3426/#3239 are NOT closed by this** — they need new detectors, see the ledger amendment. | +| `local/no-adhoc-regex-escape` | **DONE (#3951)** — widened to `MemberExpression` with a `.source`-aware exemption keyed on the property; 18 safe sites exempt, 3 provenance-exempt, 6 real findings marked. | | `scripts/lint-frontmatter-scalar-broad-grep.cjs` | **NOT a casualty of §8.1 — retained.** See the correction below. | -| `scripts/lint-phase-enumeration-drift.cjs` | expected casualty of §8.2 — **verify before retiring** (Phase 5) | +| `scripts/lint-phase-enumeration-drift.cjs` | **RETAINED** — verified at Phase 5 and not retired. #3882 migrated 2 of 23 exemptions; `readdirSync` is a raw Node API no seam makes unwritable, and 21 exemptions remain wired. See the ledger amendment. | > **Correction, 2026-08-26 (Phase 4, #3881) — two rows in this roster were wrong, and they are the > FOURTH and FIFTH wrong premises in this ADR.** Both were caught by applying the rule recorded in @@ -510,3 +510,53 @@ Net across the set: one guard retired, one increase recorded honestly. The incre > `FAILSAFE_SCHEMA` and `YAMLException` — which also makes anchors, aliases and custom types > unreachable from typed code, a capability gate rather than a shortcut. It is therefore excluded > from the byte-compare and pinned by a test instead. + +> **LEDGER AMENDMENT, 2026-08-27 (#3951) — B6's "net guard count must fall" is amended, not +> achieved, and the epic's own prescribed fix for one widening was a no-op.** This is the SIXTH +> wrong premise recorded in this ADR, found the same way as the other five: by measuring before +> building. +> +> **The count rose, and the attribution is the point.** Measured `66ad3d625` (epic filing, +> 2026-08-14) → `origin/next`: `scripts/lint-*.cjs` 38 → 44, `eslint-rules/*.cjs` 24 → 25. +> **62 → 69, delta +7.** But **five of the seven are unrelated to this epic** — three from #3753, +> plus #3582, #3409, #3619 — and one (`lint-mutation-test-derivation-drift.cjs`) was added BY a +> phase of it, #3881. The epic *did* retire one thing, sub-file: **#3884 removed Detector A** from +> `lint-unreachable-guard-drift.cjs`, ledger *"net: −1 detector, 0 added"*, because §8.4's non-zero +> `--pick` exit made the forbidden idiom correct. +> +> **Every named casualty is load-bearing, and no dead guard exists.** `lint-state-field-drift.cjs` +> and `lint-frontmatter-scalar-broad-grep.cjs` already carry retractions above; #3873 additionally +> landed `tests/lint-state-field-drift-retained.test.cjs`, which **fails on deletion**. +> `lint-phase-enumeration-drift.cjs` was verified at Phase 5 and retained: #3882 migrated **2 of 23** +> exemptions and its own commit message names six call sites that cannot migrate, because +> `readdirSync` is a raw Node API no seam makes unwritable — 21 exemptions remain wired. A sweep of +> all 22 rules and all `scripts/lint-*.cjs` found **no provably dead guard**. There is therefore no +> honest way to make the count fall; forcing it down would trade real coverage for a number, which +> is precisely the Goodhart outcome Decision 6 exists to prevent. +> +> **The B6(b) instruction as written was inert.** `eslint-rules/no-adhoc-markdown-parsing.cjs` +> short-circuits `create()` to `{}` unless the path matches `/(?:^|\/)src\/[^/]+\.cts$` — it +> **self-gates on its own filename**. Widening only the `files:` glob, which is what B6 says to do, +> ships a rule that still returns `{}` for every new path. Both halves had to move. The same regex +> was flat-only, so 28 `.cts` files in `src/` subdirectories sat inside the registered +> `src/**/*.cts` glob and were silently skipped — a latent hole (0 violations there today), fixed by +> adopting the correct form already present at `require-subprocess-timeout.cjs:196`. +> +> **And #3426/#3239 are NOT reachable by that widening.** `tests/package-legitimacy-gate.test.cjs` +> yields **zero** violations even with the gate bypassed: its hand-rolled scans are real but built +> from line filters and `split('|')`, not the regex-literal fingerprints this rule detects. They +> need new detectors. The roster row above tracked them against the wrong mechanism. +> +> **What the widenings actually cost and bought.** `no-adhoc-regex-escape` widened to +> `MemberExpression`: 27 sites by AST walk — 18 safe `X.source` (the "~10" estimate was an +> undercount), 3 provenance-exempt `_SOURCE` constants reached through required modules, **6 real +> findings**. `no-adhoc-markdown-parsing` widened to `tests/**` and `scripts/**`: **80 violations +> across 43 files**, 70 routed through the existing seams and 10 suppressed. One of the 80 was a +> test that **passed for the wrong reason** — `config-field-docs.test.cjs` asserted +> `notEqual(cell, '600')` against the *Type* column rather than *Default*, so a guard against +> `workflow.subagent_timeout` regressing to the seconds default could never fire. +> +> **The rule Decision 6 should carry going forward:** a guard ledger is a claim about COVERAGE, not +> a claim about COUNT. "Net count must fall" is measurable and wrong; "every guard is reachable, and +> each retirement names what makes its defect unrepresentable" is the property that was actually +> wanted. B6 is satisfied against the second reading and is recorded as amended against the first. diff --git a/docs/features/reachable-lint-rules-and-non-destructive-quick-task-append.md b/docs/features/reachable-lint-rules-and-non-destructive-quick-task-append.md new file mode 100644 index 000000000..6e44cbe4c --- /dev/null +++ b/docs/features/reachable-lint-rules-and-non-destructive-quick-task-append.md @@ -0,0 +1,51 @@ +--- +id: 3951 +title: Reachable Lint Rules and a Non-Destructive Quick-Task Append +group: v1.7.0 Features +--- + +**Purpose:** Make two ESLint rules cover the code they were written to govern, and stop +`quick-tasks-append` from overwriting curated `progress.*` values on a body-only write. + +**What changed:** + +- **`local/no-adhoc-markdown-parsing` reaches its whole registered surface.** The rule short-circuited + unless a file's path matched a flat `src/*.cts` pattern, so it **self-gated on its own filename**. + Two consequences: 28 `.cts` files in `src/` subdirectories sat inside the `src/**/*.cts` glob it was + registered on and were silently skipped, and the rule could not be extended by configuration at all — + widening the glob alone left it inert. Both halves now move together, and a test pins that the gate + and the registration agree in *both* directions. +- **The rule now also covers `tests/**` and `scripts/**`**, which surfaced **80 hand-rolled markdown + parses across 43 test files**. Seventy are routed through the existing `markdown-sectionizer` and + `markdown-table` seams; ten are suppressed with a stated reason (six of those are a shell-pipe + detector whose regex merely resembles a table). +- **`local/no-adhoc-regex-escape` sees property access.** Its unsafe-`new RegExp` arm examined only + bare identifiers, so `new RegExp(obj['key'])` — the shape runtime data actually arrives in — was + invisible. That is why it never fired on a known ReDoS. It now inspects `MemberExpression`, with an + exemption keyed strictly on the property being `source` (18 safe sites), plus provenance exemptions + for `_SOURCE` constants reached through a required module (3 sites). +- **`quick-tasks-append` can write the canonical row.** Optional `--quick-id`, `--slug` and + `--directory` let a caller that has a real quick task emit the same row `/gsd-quick` renders. Omit + them — as `fast.md` does, having neither an id nor a directory — and the row is byte-identical to + before. +- **A body-only append no longer re-derives progress.** The route was the only body-only STATE.md + writer not passing `{ resync: false }`, so appending one row triggered a full re-derive of the + disk-derived `progress.*` frontmatter and replaced curated values. Reproduced: a project with two + real phase directories and a curated `total_phases: 25` collapsed to `2` on append. + +**Found by the widening:** `tests/config-field-docs.test.cjs` asserted that +`workflow.subagent_timeout`'s documented default is not `600` — but read the **Type** column instead +of **Default**, so it compared `'number'` against `'600'` and could never fail. The guard against +regressing to the old seconds-based default had been inert. It is now row-scoped and real. + +**Known limits:** + +- #3426 and #3239 are **not** closed by this. Their hand-rolled scans in + `tests/package-legitimacy-gate.test.cjs` are built from line filters and `split('|')`, not the + regex-literal fingerprints this rule detects — measured at zero violations even with the gate + bypassed. They need new detectors, which is a separate design. +- The 10 suppressions are suppressions, not fixes. Each names why the raw markdown text is the + subject of that assertion. +- The `src/` subdirectory hole was **latent** — zero violations existed there when it was fixed. It is + closed because "no violations today" is not a property that keeps holding, not because it was + hiding anything. diff --git a/eslint-rules/no-adhoc-markdown-parsing.cjs b/eslint-rules/no-adhoc-markdown-parsing.cjs index 124cb12d7..46340ce6b 100644 --- a/eslint-rules/no-adhoc-markdown-parsing.cjs +++ b/eslint-rules/no-adhoc-markdown-parsing.cjs @@ -103,9 +103,26 @@ const rule = { }, create(context) { - // Only run on src/*.cts files + // Only run on src/**/*.cts, tests/**/*.cjs, and scripts/**/*.cjs files. + // `.*` (not `[^/]+`) so the gate matches subdirectories too — the + // registered glob (src/**/*.cts) already covers them + // (health-diagnostic-rules/, installer-migrations/, observability/, + // host-integration-adapters/, vendor/); a flat-only gate silently + // exempted 28 files that were supposed to be linted (#3951 B6(b)). + // Mirrors the correct form already used by + // require-subprocess-timeout.cjs's own src/**/*.cts gate. + // Widened to tests/**/*.cjs and scripts/**/*.cjs (#3951 Rung B) to match + // the registration in eslint.config.mjs — the gate and the registration + // must agree, or the rule silently returns {} for paths it is supposedly + // registered on (the exact bug this widening fixes in the other + // direction). const filename = context.getFilename ? context.getFilename() : context.filename; - if (!/(?:^|\/)src\/[^/]+\.cts$/.test(filename.replace(/\\/g, '/'))) { + const normalized = filename.replace(/\\/g, '/'); + if ( + !/(?:^|\/)src\/.*\.cts$/.test(normalized) + && !/(?:^|\/)tests\/.*\.cjs$/.test(normalized) + && !/(?:^|\/)scripts\/.*\.cjs$/.test(normalized) + ) { return {}; } diff --git a/eslint-rules/no-adhoc-regex-escape.cjs b/eslint-rules/no-adhoc-regex-escape.cjs index a6953d09b..88cd1fea7 100644 --- a/eslint-rules/no-adhoc-regex-escape.cjs +++ b/eslint-rules/no-adhoc-regex-escape.cjs @@ -300,6 +300,21 @@ function isReviewedPatternFragmentIdentifier(identifierName, scope) { return false; } +/** + * Unwrap a chain of TypeScript ` as T` casts (TSAsExpression) down to + * the innermost expression, so a cast around a MemberExpression/Identifier + * argument cannot hide its shape from the checks below. Zero sites exist in + * this repo today (#3951 Rung A measurement) — kept so a future cast cannot + * silently re-open the hole this widening closes. + */ +function unwrapTSAsExpression(node) { + let n = node; + while (n && n.type === 'TSAsExpression') { + n = n.expression; + } + return n; +} + /** Does `node` route through the seam (`escapeRegex(...)` / `literalPattern(...)`)? */ function isSeamRoutedCall(node) { if (node.type !== 'CallExpression') return false; @@ -382,18 +397,23 @@ const rule = { if (!node.callee || node.callee.type !== 'Identifier' || node.callee.name !== 'RegExp') return; const arg = node.arguments && node.arguments[0]; if (!arg) return; + // #3951 Rung A: a ` as T` cast around the argument (TSAsExpression) + // must not hide its shape from the checks below — unwrap to the + // innermost expression. Zero sites exist today; kept so a future cast + // cannot silently re-open the hole this widening closes. + const effectiveArg = unwrapTSAsExpression(arg); // Static text (string literal / no-interpolation template literal) — // fully known at lint time, not a runtime value. Never flagged. - if (arg.type === 'Literal' && typeof arg.value === 'string') return; - if (arg.type === 'TemplateLiteral' && (!arg.expressions || arg.expressions.length === 0)) return; + if (effectiveArg.type === 'Literal' && typeof effectiveArg.value === 'string') return; + if (effectiveArg.type === 'TemplateLiteral' && (!effectiveArg.expressions || effectiveArg.expressions.length === 0)) return; // Already routed through the seam — that IS the fix. - if (isSeamRoutedCall(arg)) return; + if (isSeamRoutedCall(effectiveArg)) return; - if (arg.type === 'Identifier') { + if (effectiveArg.type === 'Identifier') { const scope = context.getScope ? context.getScope() : sourceCode.getScope(node); - if (isReviewedPatternFragmentIdentifier(arg.name, scope)) { + if (isReviewedPatternFragmentIdentifier(effectiveArg.name, scope)) { return; } @@ -408,7 +428,7 @@ const rule = { // isSoleReturnOfOwnParameter's doc comment): those identifiers // never carried the `_SOURCE` naming convention in the first // place, so this branch never reaches them. - if (SOURCE_SUFFIX_RE.test(arg.name)) { + if (SOURCE_SUFFIX_RE.test(effectiveArg.name)) { if (!isAllowed(node)) { context.report({ node, messageId: 'unsafeNewRegExp' }); } @@ -418,11 +438,67 @@ const rule = { // Narrow, false-positive-free shape only — see // isSoleReturnOfOwnParameter's doc comment for why the broader // "any non-literal identifier" heuristic was rejected. - if (isSoleReturnOfOwnParameter(node, arg)) { + if (isSoleReturnOfOwnParameter(node, effectiveArg)) { if (!isAllowed(node)) { context.report({ node, messageId: 'unsafeNewRegExp' }); } } + return; + } + + // #3951 Rung A: `new RegExp(obj['key'])` / `new RegExp(cfg.pattern)` + // — the whole UNSAFE-NEW-REGEXP arm used to gate on + // `arg.type === 'Identifier'` alone, so a MemberExpression argument + // (computed or not) was never examined at all. That is why this rule + // never fired on the #3477 ReDoS (src/verify.cts:1239). + if (effectiveArg.type === 'MemberExpression') { + // (a) `.source`-exemption — `new RegExp(X.source, flags)` is the + // safe re-flag-composition idiom. Keyed ONLY on the PROPERTY being + // literally `source` (non-computed): exempting on the OBJECT + // instead would wave through `X.anything`, which buys nothing. + if (!effectiveArg.computed && effectiveArg.property && effectiveArg.property.type === 'Identifier') { + if (effectiveArg.property.name === 'source') return; + + // (b) provenance exemption, extended to MemberExpressions: the + // same NAMING CONVENTION fallback isReviewedPatternFragmentIdentifier + // already trusts for bare identifiers — a `_SOURCE`-suffixed + // constant reached through a required module namespace, e.g. + // `phaseId.BRACKET_PHASE_TOKEN_SOURCE` where + // `const phaseId = require('./phase-id.cjs')`. Bound to the + // OBJECT's actual binding kind (import / require()-derived + // const), never to spelling alone — same #3410 guard-evasion + // discipline as the bare-identifier case above. + if ( + SOURCE_SUFFIX_RE.test(effectiveArg.property.name) + && effectiveArg.object + && effectiveArg.object.type === 'Identifier' + ) { + const scope = context.getScope ? context.getScope() : sourceCode.getScope(node); + if ( + resolvesToImportBinding(effectiveArg.object.name, scope) + || resolvesToRequireDerivedConstBinding(effectiveArg.object.name, scope) + ) { + return; + } + } + } + + // Neither exemption applied — a MemberExpression argument (computed + // like `obj['key']`, or non-computed like `cfg.pattern`) with no + // proven-safe provenance is exactly ADR §7's "new RegExp() built + // from a runtime value with no escaping and no seam routing." + // Deliberately UNCONDITIONAL (unlike the bare-Identifier arm's + // narrower isSoleReturnOfOwnParameter gate): a MemberExpression + // argument categorically cannot be the "value already computed + // upstream, safely, and merely re-used" case that heuristic exists + // to avoid false-flagging — see isSoleReturnOfOwnParameter's doc + // comment. Measured across the repo: this arm's population is + // exactly the 27 `new RegExp()` sites accounted + // for above (18 `.source`, 3 provenance-exempt, 6 real findings) — + // going wider than this shape is out of scope. + if (!isAllowed(node)) { + context.report({ node, messageId: 'unsafeNewRegExp' }); + } } }, }; diff --git a/eslint.config.mjs b/eslint.config.mjs index 2603f409a..8200d2cac 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -498,6 +498,11 @@ export default tseslint.config( // ADR-3212 Phase 1 (#3412): pattern-construction seam prohibition — // see the src/**/*.cts block above for detail. 'local/no-adhoc-regex-escape': 'error', + // ADR-1372 T7 widening (#3951 Rung B): reach extended from src/**/*.cts + // to scripts/**/*.cjs — see the src/**/*.cts block above for detail. + // The rule self-gates on filename too (eslint-rules/no-adhoc-markdown-parsing.cjs), + // so registering here alone would be inert without that gate change. + 'local/no-adhoc-markdown-parsing': 'error', }, }, @@ -662,6 +667,11 @@ export default tseslint.config( // // allow-adhoc-regex-escape: comments (design doc Notes: "not a 13th // production copy"). 'local/no-adhoc-regex-escape': 'error', + // ADR-1372 T7 widening (#3951 Rung B): reach extended from src/**/*.cts + // to tests/**/*.cjs — see the src/**/*.cts block above for detail. + // The rule self-gates on filename too (eslint-rules/no-adhoc-markdown-parsing.cjs), + // so registering here alone would be inert without that gate change. + 'local/no-adhoc-markdown-parsing': 'error', // Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax 'no-restricted-syntax': [ 'error', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 8e73ee6a0..6ff98e7db 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1177,10 +1177,30 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load // disables the boundary walk (as with `init quick`) so extraction // (used for the --task form) and the `|| args[1]` fallback (used for // the positional form) both keep working unchanged. - const qtaTask = parseNamedArgsOrExit(qtaArgs, { valueFlags: ['task'], positionals: 'rest' }, error).task || args[1]; + // #3356 defect 1: `--quick-id` / `--slug` / `--directory` are + // OPTIONAL widenings. A caller with no quick id or task directory + // (fast.md, the original #2133 caller) omits them and keeps the + // exact prior ordinal-`#`/`'—'`-Directory row. A caller that DOES + // have a real quick id + task dir (i.e. can match `workflows/ + // quick.md`'s own Step 7c row for the same inputs) supplies them + // and gets the byte-equivalent canonical row `quick.md:632` + // documents — closing the false-equivalence gap `quick.md:627` + // claims. `--directory` wins outright when given explicitly; + // otherwise a supplied `--quick-id` + `--slug` pair derives the + // canonical permalink the same way `workflows/quick.md` renders it. + const qtaParsed = parseNamedArgsOrExit( + qtaArgs, + { valueFlags: ['task', 'quick-id', 'slug', 'directory'], positionals: 'rest' }, + error, + ); + const qtaTask = qtaParsed.task || args[1]; if (!qtaTask) { error('quick-tasks-append requires --task (or a positional description)', ERROR_REASON.USAGE); } + const qtaQuickId = qtaParsed['quick-id'] || undefined; + const qtaSlug = qtaParsed['slug'] || undefined; + const qtaDirectory = qtaParsed['directory'] + || (qtaQuickId && qtaSlug ? `[${qtaQuickId}-${qtaSlug}](./quick/${qtaQuickId}-${qtaSlug}/)` : undefined); const statePath = path.join(cwd, '.planning', 'STATE.md'); if (!fs.existsSync(statePath)) { @@ -1206,8 +1226,21 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load // still releases the lock before the throw propagates; the transform // throws before returning new content, so nothing is ever written). let mutation; + // #3356 defect 2: this write touches only the Quick Tasks body + // table — a single appended row — so it must not trigger the + // default full re-derive of the disk-derived `progress.*` + // frontmatter block. `{ resync: false }` mirrors every other + // body-only STATE.md writer's convention (src/state.cts's own + // docstring on `readModifyWriteStateMd` prescribes it); this route + // was the lone outlier still passing no options at all. state.readModifyWriteStateMd(statePath, (content) => { - const result = appendQuickTaskRow(content, { description: qtaTask, date, commit }); + const result = appendQuickTaskRow(content, { + description: qtaTask, + date, + commit, + quickId: qtaQuickId, + directory: qtaDirectory, + }); if (!result.ok) { // Mirrors fast.md's old "skip with a brief log" behaviour (#2133): this // is an expected, recoverable condition (no table / unrecognized @@ -1218,7 +1251,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load } mutation = result.value; return result.value.content; - }, cwd); + }, cwd, { resync: false }); output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row); } diff --git a/gsd-core/workflows/quick.md b/gsd-core/workflows/quick.md index 45a78519b..4a4c1ae58 100644 --- a/gsd-core/workflows/quick.md +++ b/gsd-core/workflows/quick.md @@ -632,7 +632,7 @@ Use `date` from init: | ${quick_id} | ${DESCRIPTION} | ${date} | ${commit_hash} | [${quick_id}-${slug}](./quick/${quick_id}-${slug}/) | ``` -For a schema-safe append outside this workflow (e.g. from fast.md), `gsd_run quick-tasks-append --task ` performs the equivalent write via the shared, schema-backed `appendQuickTaskRow` helper (#2133, ADR-2143 §3/§7). +For a schema-safe append outside this workflow (e.g. from fast.md, which has neither a quick id nor a task directory), `gsd_run quick-tasks-append --task ` performs an equivalent-shape write via the shared, schema-backed `appendQuickTaskRow` helper (#2133, ADR-2143 §3/§7) — the `#` cell is a positional ordinal and `Directory` reads `—`, since no id/directory was supplied. A caller that DOES have a real `${quick_id}` and task directory can pass `--quick-id --slug ` (or `--directory ` directly) to get the byte-identical row this step renders above (#3356). **7d. Update "Last activity" line:** diff --git a/src/markdown-table.cts b/src/markdown-table.cts index 3f42f32d2..0a532054d 100644 --- a/src/markdown-table.cts +++ b/src/markdown-table.cts @@ -739,6 +739,17 @@ export interface QuickTaskFields { commit: string; status?: string; directory?: string; + /** + * The canonical `${quick_id}` cell (#3356 defect 1). When supplied, the `#` + * cell renders this value verbatim instead of the positional ordinal + * `rows.length + 1` — matching `workflows/quick.md`'s Step 7c row shape for + * callers that actually have a quick id and task directory (e.g. a + * `--quick-id`-bearing `gsd-tools quick-tasks-append` invocation). Omitted + * (the `fast.md` caller, which has neither a quick id nor a task + * directory — #2133) falls back to the ordinal exactly as before; this is + * a pure widening, never a behavior change for existing callers. + */ + quickId?: string; } /** @@ -788,7 +799,7 @@ export function appendQuickTaskRow( const rowNumber = parsed.value.rows.length + 1; const cellFor = (col: string): string => { switch (col) { - case '#': return escapeCell(String(rowNumber)); + case '#': return escapeCell(fields.quickId ?? String(rowNumber)); case 'Description': return escapeCell(fields.description); case 'Date': return escapeCell(fields.date); case 'Commit': return escapeCell(fields.commit); diff --git a/tests/adr-218-release-version-validation.test.cjs b/tests/adr-218-release-version-validation.test.cjs index ecf670ecc..9d006d78c 100644 --- a/tests/adr-218-release-version-validation.test.cjs +++ b/tests/adr-218-release-version-validation.test.cjs @@ -141,7 +141,10 @@ describe('ADR-218 — leading-zero rejection regex (behavioral)', () => { `If the pattern was relocated or renamed, update this test to match.` ); - const re = new RegExp(minorMajorPatterns[0]); + // Pattern extracted verbatim from release.yml at test time — the shipped + // grep pattern IS the product under test; escaping it would assert a + // different string than what actually ships (#3951). + const re = new RegExp(minorMajorPatterns[0]); // allow-adhoc-regex-escape: runtime-contract-is-the-product // Boundary table: REJECTED (leading zeros or malformed) const shouldReject = [ @@ -195,7 +198,9 @@ describe('ADR-218 — leading-zero rejection regex (behavioral)', () => { `ADR-218 requires IS_MAJOR detection to also forbid leading zeros.` ); - const re = new RegExp(majorOnlyPatterns[0]); + // Pattern extracted verbatim from release.yml — the shipped grep pattern + // IS the product under test (#3951). + const re = new RegExp(majorOnlyPatterns[0]); // allow-adhoc-regex-escape: runtime-contract-is-the-product // REJECTED: leading zeros in the major segment const shouldReject = [ @@ -244,7 +249,9 @@ describe('ADR-218 — leading-zero rejection regex (behavioral)', () => { `Expected a pattern matching 1.2.3 but not 1.2.0.` ); - const re = new RegExp(hotfixPatterns[0]); + // Pattern extracted verbatim from release.yml — the shipped grep pattern + // IS the product under test (#3951). + const re = new RegExp(hotfixPatterns[0]); // allow-adhoc-regex-escape: runtime-contract-is-the-product // Sanity: valid hotfix versions accepted assert.equal(re.test('1.2.3'), true, 'Hotfix pattern must accept 1.2.3'); diff --git a/tests/adr-index-gate.test.cjs b/tests/adr-index-gate.test.cjs index 4b37463aa..9a7a2caf7 100644 --- a/tests/adr-index-gate.test.cjs +++ b/tests/adr-index-gate.test.cjs @@ -17,6 +17,7 @@ const { spawnSync } = require('node:child_process'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { copyScriptWithDeps } = require('./helpers/copy-script-fixture.cjs'); +const { findTableWithColumns } = require('../gsd-core/bin/lib/markdown-table.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const SCRIPT_REL = path.join('scripts', 'gen-adr-index.cjs'); @@ -199,7 +200,12 @@ test('subsumption is symmetry-checked but does NOT mark the target superseded', assert.match(readme, /### Active decisions\b/, 'a subsumed ADR stays Active'); // The subsumer is surfaced in the "Read first" column so EoS is discoverable // from the component ADR. - assert.match(readme, /\| \[ADR-0001\]\(0001-alpha\.md\) \|[^|]*\| Accepted \| \[ADR-900\]\(900-eos\.md\) \|/); + const table = findTableWithColumns(readme, ['ADR', 'Title', 'Status', 'Read first']); + assert.ok(table, 'the generated README has an ADR/Title/Status/Read first table'); + const row = table.rows.find((r) => r.ADR === '[ADR-0001](0001-alpha.md)'); + assert.ok(row, 'a row for ADR-0001 exists'); + assert.equal(row.Status, 'Accepted'); + assert.equal(row['Read first'], '[ADR-900](900-eos.md)'); }); test('a missing subsumption back-link is rejected', (t) => { diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs index 5803fa023..d3536cfeb 100644 --- a/tests/audit-command-cutover.test.cjs +++ b/tests/audit-command-cutover.test.cjs @@ -23,6 +23,7 @@ const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); const { routeAuditUat, routeAuditOpen } = require('../gsd-core/bin/lib/audit-command-router.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); // ─── helpers ───────────────────────────────────────────────────────────────── @@ -1363,12 +1364,14 @@ function extractFrontmatter(content) { } // Case 2: frontmatter is embedded inside a fenced block (```markdown\n---\n…\n---\n) - const fenceMatch = content.match(/```(?:markdown|md)?\r?\n(---\r?\n[\s\S]*?\r?\n---)\r?\n/); - if (fenceMatch) { - // Strip the outer --- delimiters to get just the YAML body - const block = fenceMatch[1]; - const inner = block.match(/^---\r?\n([\s\S]*?)\r?\n---$/); - return inner ? inner[1] : null; + const lines = content.split(/\r?\n/); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + const info = (block.infoString || '').trim().toLowerCase(); + if (info !== '' && info !== 'markdown' && info !== 'md') continue; + const fenced = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + const inner = fenced.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (inner) return inner[1]; } return null; diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index ac912cec0..cfcd8991b 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -16,6 +16,7 @@ const os = require('node:os'); const path = require('node:path'); const { cleanup } = require('./helpers.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const { validateCapability, @@ -7240,12 +7241,14 @@ const MANIFEST_REQUIRED_KEYS = new Set([ */ function extractManifests(mdContent) { const manifests = []; - const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g; - let match; - while ((match = fenceRe.exec(mdContent)) !== null) { + const lines = mdContent.split(/\r?\n/); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim().toLowerCase() !== 'json') continue; + const body = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); let parsed; try { - parsed = JSON.parse(match[1]); + parsed = JSON.parse(body); } catch { continue; } diff --git a/tests/code-review.test.cjs b/tests/code-review.test.cjs index 653c7bfd3..c470b40c9 100644 --- a/tests/code-review.test.cjs +++ b/tests/code-review.test.cjs @@ -24,6 +24,19 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); + +/** Return the raw text of every ```bash fenced block in `content`. */ +function extractBashBlocks(content) { + const lines = content.split(/\r?\n/); + const blocks = []; + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim().toLowerCase() !== 'bash') continue; + blocks.push(lines.slice(block.openLineIdx, block.closeLineIdx + 1).join('\n')); + } + return blocks; +} const os = require('os'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); @@ -419,8 +432,7 @@ describe('CR-WORKFLOW: code review workflow structure', () => { const content = fs.readFileSync(path.join(WORKFLOWS_DIR, 'code-review.md'), 'utf-8'); // mapfile is bash 4+ only; macOS ships bash 3.2. Dedup must use portable while-read. // Note: 'mapfile' may appear in platform_notes documentation — check bash code blocks only - // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content - const codeBlocks = content.match(/```bash[\s\S]*?```/g) || []; + const codeBlocks = extractBashBlocks(content); const hasMapfileInCode = codeBlocks.some(block => block.includes('mapfile -t')); assert.ok(!hasMapfileInCode, 'code-review.md bash code blocks use mapfile which is bash 4+ only — breaks macOS default bash 3.2'); @@ -430,8 +442,7 @@ describe('CR-WORKFLOW: code review workflow structure', () => { test('code-review-fix.md uses portable while-read loop for array construction (not mapfile)', () => { const content = fs.readFileSync(path.join(WORKFLOWS_DIR, 'code-review-fix.md'), 'utf-8'); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content - const codeBlocks = content.match(/```bash[\s\S]*?```/g) || []; + const codeBlocks = extractBashBlocks(content); const hasMapfileInCode = codeBlocks.some(block => block.includes('mapfile -t')); assert.ok(!hasMapfileInCode, 'code-review-fix.md bash code blocks use mapfile which is bash 4+ only — breaks macOS default bash 3.2'); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 0109d3727..10c69921a 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -1254,7 +1254,7 @@ describe('#3897 rung 3: sandbox_mode derivation and the hold list', () => { } }); - test('T26 newWritingRoleGetsTheContractNotThePin: a brand-new agent declaring Write, with no hold, derives workspace-write (S6) — RED today, falls back to read-only', () => { + test('T26 newWritingRoleGetsTheContractNotThePin: a brand-new agent declaring Write, with no hold, derives workspace-write (S6) — RED today, falls back to read-only (#2540)', () => { const newAgentContent = `--- name: gsd-totally-new-agent description: A brand-new writing agent that has never been in the map or a hold @@ -6577,6 +6577,7 @@ const path = require('node:path'); const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); const { getCodexSkillAdapterHeader } = INSTALL; +const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); /** * Extract the "Execute mode fallback" section text from the adapter header. @@ -6584,8 +6585,22 @@ const { getCodexSkillAdapterHeader } = INSTALL; * "Execute mode fallback:" label up to the next heading or tag. */ function extractExecuteModeFallback(header) { - const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); - return m ? m[1].trim() : null; + const label = 'Execute mode fallback:'; + const labelIdx = header.indexOf(label); + if (labelIdx === -1) return null; + const bodyStart = header.indexOf('\n', labelIdx + label.length); + if (bodyStart === -1) return null; + + // End at whichever comes first: the next "## " heading (via the canonical + // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. + const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); + const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n + const closeTagIdx = header.indexOf('', bodyStart); + const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n + const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); + if (bodyEnd === Infinity) return null; + + return header.slice(bodyStart + 1, bodyEnd).trim(); } /** @@ -9300,6 +9315,7 @@ const path = require('node:path'); const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); const { getCodexSkillAdapterHeader } = INSTALL; +const { tokenizeHeadings } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); /** * Extract the "Execute mode fallback" section text from the adapter header. @@ -9307,8 +9323,22 @@ const { getCodexSkillAdapterHeader } = INSTALL; * "Execute mode fallback:" label up to the next heading or tag. */ function extractExecuteModeFallback(header) { - const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); - return m ? m[1].trim() : null; + const label = 'Execute mode fallback:'; + const labelIdx = header.indexOf(label); + if (labelIdx === -1) return null; + const bodyStart = header.indexOf('\n', labelIdx + label.length); + if (bodyStart === -1) return null; + + // End at whichever comes first: the next "## " heading (via the canonical + // heading tokenizer, not an ad-hoc regex) or the closing adapter tag. + const headings = tokenizeHeadings(header).filter((h) => h.level === 2 && h.offset > bodyStart); + const nextHeadingOffset = headings.length > 0 ? headings[0].offset - 1 : Infinity; // -1 for the leading \n + const closeTagIdx = header.indexOf('', bodyStart); + const closeTagOffset = closeTagIdx === -1 ? Infinity : closeTagIdx - 1; // -1 for the leading \n + const bodyEnd = Math.min(nextHeadingOffset, closeTagOffset); + if (bodyEnd === Infinity) return null; + + return header.slice(bodyStart + 1, bodyEnd).trim(); } /** diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index 6007b70ba..a08772c23 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -13,10 +13,23 @@ const { describe, test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); const REFERENCE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md'); const CORE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-loader.cjs'); +/** Find the markdown table row whose first cell is `` `key` `` and return its cells. */ +function tableRowForKey(content, key) { + const target = `\`${key}\``; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + const cells = splitTableRow(line); + if (cells[0] === target) return cells; + } + return null; +} + describe('config-field-docs', () => { let content; @@ -340,16 +353,22 @@ describe('CONFIGURATION.md parity (#1216)', () => { docsContent.includes('millisecond') || docsContent.includes('milliseconds'), 'CONFIGURATION.md workflow.subagent_timeout must use the word "millisecond(s)"' ); - assert.ok( - !docsContent.match(/\|\s*`workflow\.subagent_timeout`[^|]*\|\s*`?600`?\s*\|/), - 'CONFIGURATION.md workflow.subagent_timeout must NOT have default 600 (that was the seconds default)' + const row = tableRowForKey(docsContent, 'workflow.subagent_timeout'); + assert.ok(row, 'CONFIGURATION.md must have a table row for workflow.subagent_timeout'); + assert.notEqual( + row[2].replace(/`/g, ''), + '600', + 'CONFIGURATION.md workflow.subagent_timeout default must not be 600 (that was the seconds default)' ); }); test('CONFIGURATION.md workflow.subagent_timeout default is 300000 (#1216)', () => { // Row-scoped: the actual table row for workflow.subagent_timeout must contain 300000 - assert.ok( - /\|\s*`workflow\.subagent_timeout`\s*\|[^|]*\|\s*`?300000`?\s*\|/.test(docsContent), + const row = tableRowForKey(docsContent, 'workflow.subagent_timeout'); + assert.ok(row, 'CONFIGURATION.md must have a table row for workflow.subagent_timeout'); + assert.equal( + row[2].replace(/`/g, ''), + '300000', 'CONFIGURATION.md workflow.subagent_timeout table row must have default 300000' ); }); diff --git a/tests/debug-session-management.test.cjs b/tests/debug-session-management.test.cjs index 2c05f5bfb..4db3530be 100644 --- a/tests/debug-session-management.test.cjs +++ b/tests/debug-session-management.test.cjs @@ -9,6 +9,7 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); describe('debug session management implementation', () => { test('DEBUG.md template contains reasoning_checkpoint field', () => { @@ -213,11 +214,20 @@ describe('debug skill dispatch and sub-orchestrator (#2148, #2151)', () => { assert.ok(debugMatch, 'DEBUG.md must state a "N-field structured reasoning record" claim for reasoning_checkpoint'); const claimedCount = /^\d+$/.test(debugMatch[1]) ? parseInt(debugMatch[1], 10) : NUMWORDS[debugMatch[1].toLowerCase()]; assert.ok(typeof claimedCount === 'number', `unrecognized field-count token: ${debugMatch[1]}`); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own agent .md content, fixed-size author-controlled content - const yamlBlock = agentContent.match(/reasoning_checkpoint:\s*\r?\n([\s\S]*?)```/); - assert.ok(yamlBlock, 'gsd-debugger.md must define a fenced reasoning_checkpoint YAML block'); + const agentLines = agentContent.split(/\r?\n/); + let yamlBody = null; + for (const block of scanFencedBlocks(agentLines)) { + if (block.closeLineIdx === -1) continue; + const bodyLines = agentLines.slice(block.openLineIdx + 1, block.closeLineIdx); + const labelIdx = bodyLines.findIndex((l) => /^reasoning_checkpoint:\s*$/.test(l)); + if (labelIdx !== -1) { + yamlBody = bodyLines.slice(labelIdx + 1).join('\n'); + break; + } + } + assert.ok(yamlBody, 'gsd-debugger.md must define a fenced reasoning_checkpoint YAML block'); const keys = new Set(); - for (const line of yamlBlock[1].split(/\r?\n/)) { + for (const line of yamlBody.split(/\r?\n/)) { // Match 2-space-indented YAML keys (with OR without an inline value — // array-valued keys like confirming_evidence: have no trailing space). const m = line.match(/^ {2}([a-z_]+):/); diff --git a/tests/drift-detection.test.cjs b/tests/drift-detection.test.cjs index f7967d923..d8b973990 100644 --- a/tests/drift-detection.test.cjs +++ b/tests/drift-detection.test.cjs @@ -25,6 +25,7 @@ const { } = require('./helpers.cjs'); const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs'); const { runHook } = require('./helpers/process-seam.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const DRIFT_PATH = path.join( __dirname, @@ -843,10 +844,13 @@ function readGate() { // Extract the Nth (0-based) ```bash fenced block body from the file. function bashBlock(content, n) { + const lines = content.split(/\r?\n/); const blocks = []; - const re = /```bash\r?\n([\s\S]*?)```/g; - let m; - while ((m = re.exec(content)) !== null) blocks.push(m[1]); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim().toLowerCase() !== 'bash') continue; + blocks.push(lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n')); + } assert.ok(blocks.length > n, `expected at least ${n + 1} bash blocks, found ${blocks.length}`); return blocks[n]; } diff --git a/tests/edge-probe-docs-fixtures.test.cjs b/tests/edge-probe-docs-fixtures.test.cjs index a155445be..4db1d912e 100644 --- a/tests/edge-probe-docs-fixtures.test.cjs +++ b/tests/edge-probe-docs-fixtures.test.cjs @@ -15,6 +15,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const docPath = path.join(__dirname, '..', 'gsd-core', 'references', 'edge-probe.md'); const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'edge-probe-fixtures'); @@ -25,10 +26,15 @@ const specTemplatePath = path.join(__dirname, '..', 'gsd-core', 'templates', 'sp // The \n? before the closing fence allows blocks whose closing fence has no preceding newline // (fixes the silent-skip bug where a trailing-fence-with-no-newline was not matched). function taggedJsonBlocks(md) { - const re = /```json edge-probe:([^\r\n]+)\r?\n([\s\S]*?)\r?\n?```/g; + const lines = md.split(/\r?\n/); const out = {}; - let m; - while ((m = re.exec(md))) out[m[1].trim()] = m[2]; + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + const info = block.infoString || ''; + const tagMatch = /^json edge-probe:([^\r\n]+)$/.exec(info); + if (!tagMatch) continue; + out[tagMatch[1].trim()] = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + } return out; } diff --git a/tests/edit-phase.test.cjs b/tests/edit-phase.test.cjs index bcbcf0875..44e1c2af2 100644 --- a/tests/edit-phase.test.cjs +++ b/tests/edit-phase.test.cjs @@ -23,6 +23,7 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); const ROOT = path.resolve(__dirname, '..'); @@ -384,10 +385,12 @@ describe('edit-phase: documentation registration', () => { ); // Locate the edit-phase.md row in the Workflows table and assert the // "Invoked by" column documents /gsd-phase --edit (not the deleted form). - // eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored docs/INVENTORY.md, bounded table rows, not adversarial input - const rowMatch = inventory.match(/^\|\s*`edit-phase\.md`\s*\|[^|]*\|\s*([^|]+?)\s*\|$/m); - assert.ok(rowMatch, 'docs/INVENTORY.md must contain an edit-phase.md workflow row'); - const invokedBy = rowMatch[1]; + const row = inventory + .split(/\r?\n/) + .map((line) => (line.trim().startsWith('|') ? splitTableRow(line) : null)) + .find((cells) => cells && cells[0] === '`edit-phase.md`'); + assert.ok(row, 'docs/INVENTORY.md must contain an edit-phase.md workflow row'); + const invokedBy = row[2]; assert.ok( /\/gsd-phase\s+--edit/.test(invokedBy), `edit-phase.md row must list "/gsd-phase --edit" as caller; got: "${invokedBy}"` diff --git a/tests/eslint-no-adhoc-regex-escape.test.cjs b/tests/eslint-no-adhoc-regex-escape.test.cjs index ebf038d8a..51b70e0a3 100644 --- a/tests/eslint-no-adhoc-regex-escape.test.cjs +++ b/tests/eslint-no-adhoc-regex-escape.test.cjs @@ -288,6 +288,124 @@ describe('no-adhoc-regex-escape rule', () => { }); }); + // ── #3951 Rung A: UNSAFE-NEW-REGEXP widened to MemberExpression ─────────── + // Design: .gsd/phase/feat-3951-b6-b7-guard-ledger/40-design.md "Rung A". + // The whole arm used to gate on `arg.type === 'Identifier'`, so a + // MemberExpression argument (`obj['key']`, `cfg.pattern`) was never + // examined — the reason this rule never fired on the #3477 ReDoS. + + test('#3951 invalid: new RegExp(obj[\'key\']) — a computed MemberExpression is now examined', () => { + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [], + invalid: [ + { + code: `const re = new RegExp(obj['key']);`, + filename: 'src/some-module.cts', + errors: [{ messageId: 'unsafeNewRegExp' }], + }, + ], + }); + }); + + test('#3951 invalid: new RegExp(cfg.pattern) — a non-computed MemberExpression is now examined', () => { + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [], + invalid: [ + { + code: `const re = new RegExp(cfg.pattern);`, + filename: 'src/some-module.cts', + errors: [{ messageId: 'unsafeNewRegExp' }], + }, + ], + }); + }); + + test('#3951 valid: new RegExp(X.source, flags) — the safe re-flag-composition idiom is NOT flagged', () => { + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [ + { code: `const re = new RegExp(existingPattern.source, 'g');`, filename: 'src/some-module.cts' }, + ], + invalid: [], + }); + }); + + test('#3951 valid: new RegExp(config.pattern.source) — a second .source site is NOT flagged', () => { + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [ + { code: `const re = new RegExp(config.pattern.source);`, filename: 'src/some-module.cts' }, + ], + invalid: [], + }); + }); + + test('#3951 invalid: X.anything (not literally .source) is still flagged — the exemption keys on the PROPERTY only', () => { + // Guards against the design doc's stated failure mode: exempting on the + // OBJECT instead of the PROPERTY would wave through `X.anything`. + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [], + invalid: [ + { + code: `const re = new RegExp(existingPattern.anything);`, + filename: 'src/some-module.cts', + errors: [{ messageId: 'unsafeNewRegExp' }], + }, + ], + }); + }); + + test('#3951 valid: a _SOURCE constant reached through a required module namespace is NOT flagged', () => { + // tests/continuation-grammar-parity.test.cjs:124,174,374's real shape — + // `const phaseId = require('../gsd-core/bin/lib/phase-id.cjs')`, then + // `new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE)`. Same provenance-exempt + // class isReviewedPatternFragmentIdentifier already trusts for bare + // identifiers, extended to a MemberExpression on a require()-derived + // module-scope const. + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [ + { + code: ` + const phaseId = require('../gsd-core/bin/lib/phase-id.cjs'); + const re = new RegExp(phaseId.BRACKET_PHASE_TOKEN_SOURCE); + `, + filename: 'tests/continuation-grammar-parity.test.cjs', + }, + ], + invalid: [], + }); + }); + + test('#3951 regression: new RegExp(someIdentifier) still behaves exactly as before (sole-return-of-parameter still fires)', () => { + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [], + invalid: [ + { + code: String.raw` + function buildLiteralMatcher(userSuppliedValue) { + return new RegExp(userSuppliedValue); + } + `, + filename: 'src/some-module.cts', + errors: [{ messageId: 'unsafeNewRegExp' }], + }, + ], + }); + }); + + test('#3951 regression: new RegExp() still NOT flagged', () => { + ruleTester.run('no-adhoc-regex-escape', noAdhocRegexEscape, { + valid: [ + { + code: String.raw` + const src = someHelper(x); + const re = new RegExp(src); + `, + filename: 'src/some-module.cts', + }, + ], + invalid: [], + }); + }); + // ── ReDoS regression — scripts/lint-no-adhoc-regex-escape.cjs's own regex ─ test('an adversarial [] run after .replace(/ terminates instead of backtracking exponentially (#3412)', () => { diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs index f2625b850..7c520b892 100644 --- a/tests/eslint-rules.test.cjs +++ b/tests/eslint-rules.test.cjs @@ -1744,6 +1744,102 @@ describe('no-adhoc-markdown-parsing rule', () => { assert.strictEqual(typeof noAdhocMarkdownParsing.create, 'function'); }); + // ── #3951 B6(b): filename-gate reach — src/**/*.cts, subdirectories included ── + // The gate used to be `/(?:^|\/)src\/[^/]+\.cts$/` (flat-only), which + // silently exempted 28 files in src/ subdirectories + // (health-diagnostic-rules/, installer-migrations/, observability/, + // host-integration-adapters/, vendor/) even though the eslint.config.mjs + // registration (src/**/*.cts) already covers them. These three rows pin + // that the gate and the registration agree — a subdirectory path is + // linted, a flat src/ path keeps working, and a path outside src/ stays + // exempt. + + test('invalid: a table-regex fingerprint under a src/ SUBDIRECTORY is linted (gate reach)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const cellPattern = /\|[^|]*\|/;`, + filename: 'src/health-diagnostic-rules/some-check.cts', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('valid: the same fingerprint under a FLAT src/*.cts path still is linted (regression, not exempt)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const cellPattern = /\|[^|]*\|/;`, + filename: 'src/some-module.cts', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('valid: the same fingerprint OUTSIDE src/+tests/+scripts/ is NOT linted (gate and registration must agree)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [ + { + code: String.raw`const cellPattern = /\|[^|]*\|/;`, + filename: 'gsd-core/bin/lib/foo.cjs', + }, + ], + invalid: [], + }); + }); + + // ── #3951 Rung B: filename-gate reach — tests/**/*.cjs and scripts/**/*.cjs ── + // The gate self-restricted to src/**/*.cts only. eslint.config.mjs also + // registers the rule on tests/**/*.cjs and scripts/**/*.cjs (Rung B); these + // rows pin that the gate and the registration agree for BOTH new globs — + // a path each registration covers must not be silently skipped by the + // gate, and a path outside all three globs stays exempt (mirrors the + // src/ subdirectory rows above, which pinned the same contract for #3951 + // B6(b)). + + test('invalid: a table-regex fingerprint under tests/**/*.cjs is linted (gate/registration parity)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const cellPattern = /\|[^|]*\|/;`, + filename: 'tests/some.test.cjs', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('invalid: a table-regex fingerprint under a tests/ SUBDIRECTORY is linted (gate reach)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const cellPattern = /\|[^|]*\|/;`, + filename: 'tests/fixtures/some.test.cjs', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('invalid: a table-regex fingerprint under scripts/**/*.cjs is linted (gate/registration parity)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const cellPattern = /\|[^|]*\|/;`, + filename: 'scripts/some-tool.cjs', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + // ── POSITIVE cases: flag fence-block-strip and section-collect ──────────── test('invalid: fence-block-strip regex with triple-backtick and multiline body', () => { @@ -1882,18 +1978,35 @@ describe('no-adhoc-markdown-parsing rule', () => { }); }); - test('valid: rule is inert outside src/*.cts files', () => { + // #3951 Rung B: the gate's reach is src/**/*.cts, tests/**/*.cjs and + // scripts/**/*.cjs — the same fingerprints under those three roots are now + // linted, and the negative space (a path outside all three) stays exempt. + test('invalid: fence-block-strip and section-collect fingerprints under tests/ and scripts/ are now flagged (gate/registration parity)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + // Same fence-block-strip regex under tests/**/*.cjs → now linted + code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`, + filename: 'tests/some.test.cjs', + errors: [{ messageId: 'fenceRegex' }], + }, + { + // Same section-collect regex under scripts/**/*.cjs → now linted + code: String.raw`const p = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`, + filename: 'scripts/helper.cjs', + errors: [{ messageId: 'sectionCollect' }], + }, + ], + }); + }); + + test('valid: the same fence-block-strip fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged (negative space preserved)', () => { ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { valid: [ { - // Same fence-block-strip regex in a test file → rule does not apply code: String.raw`const stripFences = /~~~[\s\S]*?~~~/;`, - filename: 'tests/some.test.cjs', - }, - { - // Same regex in a scripts file → rule does not apply - code: String.raw`const p = /(##\s*X\n)([\s\S]*?)(?=\n##|$)/;`, - filename: 'scripts/helper.cjs', + filename: 'gsd-core/bin/lib/foo.cjs', }, ], invalid: [], @@ -1975,12 +2088,28 @@ describe('no-adhoc-markdown-parsing rule', () => { }); }); - test('valid: table-regex in a non-src/*.cts file is not flagged (rule is inert there)', () => { + // #3951 Rung B: table-regex under scripts/**/*.cjs is now linted (gate/ + // registration parity); the same fingerprint outside src/+tests/+scripts/ + // stays exempt (negative space preserved). + test('invalid: table-regex under scripts/**/*.cjs is now flagged (gate/registration parity)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const rowRe = /\|[^|]*\|/;`, + filename: 'scripts/helper.cjs', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('valid: the same table-regex fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged', () => { ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { valid: [ { code: String.raw`const rowRe = /\|[^|]*\|/;`, - filename: 'scripts/helper.cjs', + filename: 'gsd-core/bin/lib/foo.cjs', }, ], invalid: [], @@ -2110,12 +2239,27 @@ describe('no-adhoc-markdown-parsing rule', () => { }); }); - test('valid: new RegExp(...) table-regex in a non-src/*.cts file is not flagged', () => { + // #3951 Rung B: a new RegExp(...) table-regex under tests/**/*.cjs is now + // linted; the same fingerprint outside src/+tests/+scripts/ stays exempt. + test('invalid: new RegExp(...) table-regex under tests/**/*.cjs is now flagged (gate/registration parity)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`, + filename: 'tests/some.test.cjs', + errors: [{ messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('valid: the same new RegExp(...) table-regex fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged', () => { ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { valid: [ { code: String.raw`const rowRe = new RegExp('\\|[^|]*\\|');`, - filename: 'scripts/helper.cjs', + filename: 'gsd-core/bin/lib/foo.cjs', }, ], invalid: [], @@ -2206,12 +2350,28 @@ describe('no-adhoc-markdown-parsing rule', () => { }); }); - test('valid: .replace() ad-hoc mutation in a non-src/*.cts file is not flagged', () => { + // #3951 Rung B: an ad-hoc .replace() mutation under scripts/**/*.cjs is now + // linted (both the CallExpression and its Literal argument fire); the same + // fingerprint outside src/+tests/+scripts/ stays exempt. + test('invalid: .replace() ad-hoc mutation under scripts/**/*.cjs is now flagged (gate/registration parity)', () => { + ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { + valid: [], + invalid: [ + { + code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`, + filename: 'scripts/helper.cjs', + errors: [{ messageId: 'adhocReplaceMutation' }, { messageId: 'tableRegex' }], + }, + ], + }); + }); + + test('valid: the same .replace() ad-hoc mutation fingerprint OUTSIDE src/+tests/+scripts/ stays NOT flagged', () => { ruleTester.run('no-adhoc-markdown-parsing', noAdhocMarkdownParsing, { valid: [ { code: String.raw`roadmapContent.replace(/\|[^|]*\|/, 'x');`, - filename: 'scripts/helper.cjs', + filename: 'gsd-core/bin/lib/foo.cjs', }, ], invalid: [], diff --git a/tests/estimate-calibrate.test.cjs b/tests/estimate-calibrate.test.cjs index fe37b95be..c087534fd 100644 --- a/tests/estimate-calibrate.test.cjs +++ b/tests/estimate-calibrate.test.cjs @@ -486,6 +486,39 @@ describe('sentinel phases must not skew calibration (#3882)', () => { 'the two genuine phases must still contribute their own, unchanged samples', ); }); + + // A1a-A3 above only assert against the `collectCalibrationSamples` helper's + // return value. Per ADR-3180 Decision 4(b) / epic #3473 B7, a behavioral + // identity test must assert at the CONSUMER's output — the real `query + // estimate-calibrate` CLI JSON and the persisted calibration document it + // writes, not the internal sample array. #3372 named this exact surface + // (`src/estimate-cli.cts`) as one of four candidate sentinel-enumeration + // gaps; #3882 confirmed and fixed it. This closes the CLI-level gap. + test('CLI (#3372, #3882): query estimate-calibrate excludes sentinel phase directories from the emitted sample_count and the persisted document', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + + writePhase(tmpDir, '01-alpha', { estTokens: 1000, actTokens: 1000 }); + writePhase(tmpDir, '02-beta', { estTokens: 2000, actTokens: 4000 }); + // milestone 999 — reserved icebox sentinel range (SENTINEL_RANGES). + writePhase(tmpDir, '999-icebox', { estTokens: 1000, actTokens: 50000 }); + + const r = runGsdTools('query estimate-calibrate', tmpDir); + assert.ok(r.success, `estimate-calibrate should succeed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.equal( + out.sample_count, 2, + `the sentinel phase directory must not be counted in the CLI's emitted sample_count; got ${JSON.stringify(out)}`, + ); + + const docPath = path.join(tmpDir, '.planning', 'estimation-calibration.json'); + const persisted = est.parseCalibrationDocument(fs.readFileSync(docPath, 'utf8')); + assert.equal( + persisted.length, 2, + `the persisted calibration document must not carry the sentinel phase's sample; got ${JSON.stringify(persisted)}`, + ); + }); }); // ─── PhasesUnreadableError / estimate_phases_unreadable (#3882, ADR-3473 §8.5, diff --git a/tests/execute-phase-active-flags.test.cjs b/tests/execute-phase-active-flags.test.cjs index c30015b61..3d0104f5e 100644 --- a/tests/execute-phase-active-flags.test.cjs +++ b/tests/execute-phase-active-flags.test.cjs @@ -15,6 +15,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const COMMAND_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'execute-phase.md'); @@ -140,11 +141,12 @@ function assertConfigGetBeforeMakefile(filePath, label) { // Extract bash blocks to check ordering within each block. // Use the actual Makefile test ([ -f "Makefile" ]) not just the word "Makefile" // (which appears in comments before the config-get call). - const bashBlockRe = /```bash([\s\S]*?)```/g; - let match; + const lines = content.split(/\r?\n/); let anyBlockCorrectlyOrdered = false; - while ((match = bashBlockRe.exec(content)) !== null) { - const block = match[1]; + for (const fenced of scanFencedBlocks(lines)) { + if (fenced.closeLineIdx === -1) continue; + if ((fenced.infoString || '').trim() !== 'bash') continue; + const block = lines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx).join('\n'); if (block.includes('workflow.test_command') && block.includes('[ -f "Makefile"')) { const configIdx = block.indexOf('workflow.test_command'); const makefileIdx = block.indexOf('[ -f "Makefile"'); diff --git a/tests/frontmatter.test.cjs b/tests/frontmatter.test.cjs index e98c4ab6e..4a1652e1e 100644 --- a/tests/frontmatter.test.cjs +++ b/tests/frontmatter.test.cjs @@ -20,6 +20,7 @@ const { } = require('../gsd-core/bin/lib/frontmatter.cjs'); const { normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); // ─── extractFrontmatter ───────────────────────────────────────────────────── @@ -2556,8 +2557,24 @@ function extractStep(content, stepName) { * if no fenced bash block is found. */ function extractFirstBashBlock(stepText) { - const m = /```bash\r?\n([\s\S]*?)```/.exec(stepText); - return m ? m[1] : null; + const lines = stepText.split(/\r?\n/); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + return lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + } + return null; +} + +/** Remove the FIRST ```bash ... ``` fenced block (fence lines included) from `stepText`. */ +function stripFirstBashBlock(stepText) { + const lines = stepText.split(/\r?\n/); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + return lines.slice(0, block.openLineIdx).concat(lines.slice(block.closeLineIdx + 1)).join('\n'); + } + return stepText; } /** @@ -2641,7 +2658,7 @@ describe('#2847: gsd-planner.md validate_plan step BINDS --schema to gap_closure // The SAME variable name the bash block reads must appear in the step's prose // (outside the bash block) — otherwise the "binding" is a variable nothing // ever explains how to set, which is not meaningfully better than a literal. - const proseOutsideBash = validateStep.replace(/```bash\r?\n[\s\S]*?```/, ''); + const proseOutsideBash = stripFirstBashBlock(validateStep); assert.ok( proseOutsideBash.includes(`$${varName}`) || proseOutsideBash.includes(`\`$${varName}\``), `step prose must explain how $${varName} is set — the bash block references it but nothing binds it` diff --git a/tests/gen-state-md-docs.test.cjs b/tests/gen-state-md-docs.test.cjs index 00e27fcfc..aef7c3ac6 100644 --- a/tests/gen-state-md-docs.test.cjs +++ b/tests/gen-state-md-docs.test.cjs @@ -318,8 +318,11 @@ describe('gen-state-md-docs.cjs generated template validity (#3873 row 27)', () // the first ```markdown ... ``` fence by raw regex and assert directly on // its content, exactly as an external consumer (an AI agent creating // .planning/STATE.md, or gsd-tools reading the shipped template) would. + // Deliberately independent of markdown-sectionizer — this guards the #3873 + // regression class (a defect IN the generator's own fence-handling), so it + // must not share the same seam the generator uses. // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own state.md template, fixed-size author-controlled content - const fenced = templateText.match(/```markdown\r?\n([\s\S]*?)```/); + const fenced = templateText.match(/```markdown\r?\n([\s\S]*?)```/); // allow-adhoc-markdown: deliberately independent of the generator's own fence-handling — regresses #3873 assert.ok(fenced, 'the File Template section must contain a ```markdown fenced block'); const body = fenced[1]; diff --git a/tests/graphify-visualization.test.cjs b/tests/graphify-visualization.test.cjs index a7586eebc..0ec18f229 100644 --- a/tests/graphify-visualization.test.cjs +++ b/tests/graphify-visualization.test.cjs @@ -16,6 +16,7 @@ const { createTempProject, createTempGitProject, cleanup } = require('./helpers. const { graphifyStatus, } = require('../gsd-core/bin/lib/graphify.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const { enableGraphify, @@ -636,10 +637,16 @@ const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md') */ function extractStep3Block() { const content = readFileNormalized(GRAPHIFY_MD); - // Capture the full body of the ```bash fence that CONTAINS `graphify update .` - // (including any leading preamble line), without crossing into other fences. - const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/); - return match ? match[1].trim() : null; + // Find the ```bash fence that CONTAINS `graphify update .` (including any + // leading preamble line), without crossing into other fences. + const lines = content.split('\n'); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + const body = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + if (body.includes('graphify update .')) return body.trim(); + } + return null; } // ─── shared sandbox dirs ────────────────────────────────────────────────────── diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index c140904a5..2c6fb65a0 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -33,6 +33,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'); const PROJECTION_PATH = path.join( @@ -630,10 +631,12 @@ describe('bug-2784: update.md cache-clear covers shared cache path', () => { const stepContent = stepMatch[0]; const bashLines = []; - const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g; - let m; - while ((m = fenceRe.exec(stepContent)) !== null) { - for (const line of m[1].split(/\r?\n/)) { + const stepLines = stepContent.split(/\r?\n/); + for (const block of scanFencedBlocks(stepLines)) { + if (block.closeLineIdx === -1) continue; + const info = (block.infoString || '').trim(); + if (info !== 'bash' && info !== 'sh') continue; + for (const line of stepLines.slice(block.openLineIdx + 1, block.closeLineIdx)) { const trimmed = line.trim(); if (trimmed) bashLines.push(trimmed); } diff --git a/tests/host-integration.test.cjs b/tests/host-integration.test.cjs index 69a8655da..613c7742e 100644 --- a/tests/host-integration.test.cjs +++ b/tests/host-integration.test.cjs @@ -36,6 +36,7 @@ const { validateCapability, } = require('../gsd-core/bin/lib/capability-validator.cjs'); const { cleanup, readFileNormalized } = require('./helpers.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -3135,8 +3136,12 @@ describe('#2728 B1 — isolation degrades re-record through the single write pat */ function bashBlockContaining(file, marker) { const text = readFileNormalized(file); - for (const m of text.matchAll(/```bash\r?\n([\s\S]*?)```/g)) { - if (m[1].includes(marker)) return m[1]; + const lines = text.split('\n'); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + const body = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + if (body.includes(marker)) return body; } assert.fail(`no \`\`\`bash block containing ${JSON.stringify(marker)} in ${file}`); } @@ -3297,12 +3302,14 @@ describe('#2728 B1 — isolation degrades re-record through the single write pat const rel = path.relative(REPO_ROOT, file).replace(/\\/g, '/'); if (DELEGATED_TO_PER_PLAN_GATE.has(rel)) continue; const text = readFileNormalized(file); - for (const m of text.matchAll(/```bash\r?\n([\s\S]*?)```/g)) { - const block = m[1]; + const lines = text.split('\n'); + for (const fenced of scanFencedBlocks(lines)) { + if (fenced.closeLineIdx === -1) continue; + if ((fenced.infoString || '').trim() !== 'bash') continue; + const block = lines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx).join('\n'); if (!/^\s*ISOLATION=none\s*$/m.test(block)) continue; if (!block.includes('--force-isolation')) { - const line = text.slice(0, m.index).split(/\r?\n/).length; - offenders.push(`${rel}:${line}`); + offenders.push(`${rel}:${fenced.openLineIdx + 1}`); } } } diff --git a/tests/ingest-docs.test.cjs b/tests/ingest-docs.test.cjs index f2239ebac..209f07ddd 100644 --- a/tests/ingest-docs.test.cjs +++ b/tests/ingest-docs.test.cjs @@ -15,6 +15,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.join(__dirname, '..'); const CMD_PATH = path.join(ROOT, 'commands', 'gsd', 'ingest-docs.md'); @@ -23,6 +24,18 @@ const CLASSIFIER_PATH = path.join(ROOT, 'agents', 'gsd-doc-classifier.md'); const SYNTHESIZER_PATH = path.join(ROOT, 'agents', 'gsd-doc-synthesizer.md'); const CONFLICT_ENGINE_PATH = path.join(ROOT, 'gsd-core', 'references', 'doc-conflict-engine.md'); +/** Return the raw text of every ```bash fenced block in `content`. */ +function extractBashBlocks(content) { + const lines = content.split(/\r?\n/); + const blocks = []; + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + blocks.push(lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n')); + } + return blocks; +} + // ─── File Existence ──────────────────────────────────────────────────────────── describe('ingest-docs file structure (#2387)', () => { @@ -429,12 +442,7 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () => test('no bash code block in ingest-docs.md calls gsd-sdk', () => { const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); // Extract bash fenced code blocks structurally. - const bashBlocks = []; - const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; - let m; - while ((m = codeBlockRe.exec(content)) !== null) { - bashBlocks.push(m[1]); - } + const bashBlocks = extractBashBlocks(content); assert.ok(bashBlocks.length > 0, 'expected bash code blocks in workflow'); // Check every line in every bash block — not just lines that start with the token, @@ -454,9 +462,8 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () => test('ingest-docs.md init step uses the gsd_run launcher (#637)', () => { const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); // Parse fenced bash blocks structurally — do not match raw markdown text. - const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; - const bashLines = [...content.matchAll(codeBlockRe)] - .flatMap((m) => m[1].split('\n')) + const bashLines = extractBashBlocks(content) + .flatMap((block) => block.split('\n')) .filter((l) => !/^\s*#/.test(l)); // #637 routes ingest-docs through the resolved `gsd_run` launcher instead of // the hardcoded `node "$HOME/.../gsd-tools.cjs"` path (which misses global diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 8264d31ba..ccc96231b 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -56,6 +56,7 @@ const { normalizeNodePath } = require('../gsd-core/bin/lib/runtime-hooks-surface const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs'); const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs'); +const { findTableWithColumns } = require('../gsd-core/bin/lib/markdown-table.cjs'); // #2874 AC3 exemplar (see the qwen install/uninstall group below): resolves // the SAME 'full' profile install(false, ) resolves by default // (bin/install.js's _activeProfileName falls back to 'full' when no @@ -1879,12 +1880,12 @@ describe('#767 Parity: docs/AGENTS.md "Disallowed Tools" rows match READONLY_AGE const sectionEnd = nextSectionIdx === -1 ? agentsDoc.length : nextSectionIdx; const section = agentsDoc.slice(agentHeaderIdx, sectionEnd); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored docs/AGENTS.md table row, bounded, not adversarial input - const disallowedMatch = section.match(/\|\s*\*\*Disallowed Tools\*\*\s*\|\s*([^|]+)\|/); - assert.ok(disallowedMatch, + const table = findTableWithColumns(section, ['Property', 'Value']); + const row = table && table.rows.find((r) => r.Property === '**Disallowed Tools**'); + assert.ok(row, `docs/AGENTS.md section for ${agent} must have a "Disallowed Tools" table row`); - const docTools = disallowedMatch[1].trim(); + const docTools = row.Value.trim(); assert.equal(docTools, expectedTools, `docs/AGENTS.md "Disallowed Tools" for ${agent} must be "${expectedTools}" but got "${docTools}"`); }); @@ -6478,6 +6479,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { runNode, OUTCOME } = require('./helpers/process-seam.cjs'); const os = require('node:os'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); // A single short CLI query (install.js --skills-root ) — no full // install or build involved. @@ -7068,7 +7070,10 @@ describe('sync-skills.md — required behavioral specs', () => { // elsewhere is held to the same rule. test('every $DEST_ROOT read is preceded by a DEST_ROOT= assignment in the same bash block', () => { content = content || readWorkflow(); - const bashBlocks = [...content.matchAll(/```bash\r?\n([\s\S]*?)```/g)].map((m) => m[1]); + const __destRootLines = content.split(/\r?\n/); + const bashBlocks = scanFencedBlocks(__destRootLines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') + .map((b) => __destRootLines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n')); assert.ok( bashBlocks.length > 0, 'extractor matched no fenced ```bash blocks at all — the workflow must contain some' @@ -7327,6 +7332,7 @@ const NO_BASH = process.platform === 'win32'; test('real install: cursor negotiates --worktree through its own emitted gate and it lands in the emitted Agent() slot (#2652)', { skip: NO_BASH }, (t) => { const { readFileNormalized } = require('./helpers.cjs'); + const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-inst-cursor-gate-')); t.after(() => cleanup(dir)); // Through the process seam and the install isolation seam, never a @@ -7370,10 +7376,15 @@ test('real install: cursor negotiates --worktree through its own emitted gate an // subject is "does the emitted gate resolve cursor correctly" failed as // "there is no such block". const gateText = readFileNormalized(gate); + const gateLines = gateText.split('\n'); + const gateBashBlocks = scanFencedBlocks(gateLines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash'); const blockUnder = (heading) => { - const at = gateText.indexOf(`## ${heading}`); - if (at === -1) return undefined; - return (gateText.slice(at).match(/```bash\r?\n([\s\S]*?)```/) || [])[1]; + const headingLineIdx = gateLines.findIndex((l) => l.includes(`## ${heading}`)); + if (headingLineIdx === -1) return undefined; + const block = gateBashBlocks.find((b) => b.openLineIdx > headingLineIdx); + if (!block) return undefined; + return gateLines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); }; const resolveBlock = blockUnder('Resolve ISOLATION'); const flagBlock = blockUnder('Resolve the harness flag'); diff --git a/tests/markdown-table.test.cjs b/tests/markdown-table.test.cjs index 152189eec..45ebea55d 100644 --- a/tests/markdown-table.test.cjs +++ b/tests/markdown-table.test.cjs @@ -460,6 +460,152 @@ describe('appendQuickTaskRow (#2133)', () => { }); }); +// ─── quick-tasks-append: canonical row + no forced progress re-derive (#3356) ── +// +// #3356 named two independent, silent (exit 0, ok:true) defects in +// `routeQuickTasksAppend` (gsd-core/bin/gsd-tools.cjs): (1) the emitted `#` +// cell was hardcoded to a positional ordinal and `Directory` hardcoded to +// `'—'`, so the row could never match the canonical row `workflows/ +// quick.md`'s Step 7c documents even though quick.md:635 (pre-fix :617) +// claimed the CLI performs "the equivalent write"; (2) the route called +// `readModifyWriteStateMd` with no `options`, so `resync` defaulted `true` +// and a single Quick Tasks body-table row append forced a full disk +// re-derive of the `progress.*` frontmatter block, silently overwriting any +// curated counters that diverge from what's on disk. +// +// Both are fixed here: `appendQuickTaskRow`'s `QuickTaskFields.quickId` +// widens the pure row constructor (unit-level, this describe block), and +// `routeQuickTasksAppend` now (a) accepts `--quick-id`/`--slug`/`--directory` +// to render the canonical row, and (b) passes `{ resync: false }`. Per +// ADR-3180 Decision 4(b) / epic #3473 B7, the two CLI-level tests below drive +// the real `gsd-tools quick-tasks-append` subcommand and assert on ITS +// emitted output / the STATE.md it writes — not on `appendQuickTaskRow`'s +// return value alone, which a helper-only test would leave unproven at the +// consumer boundary. +describe('appendQuickTaskRow quickId widening (#3356 defect 1, unit)', () => { + const emptyState = [ + '# STATE', + '', + '### Quick Tasks Completed', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '', + '### Blockers/Concerns', + 'None', + ].join('\n'); + + test('a supplied quickId renders in the `#` cell instead of the positional ordinal', () => { + const result = appendQuickTaskRow(emptyState, { + description: 'Arreglar quick-tasks-append', + date: '2026-08-11', + commit: '0d3c644', + quickId: '260811-gfl', + directory: '[260811-gfl-arreglar-quick-tasks-append](./quick/260811-gfl-arreglar-quick-tasks-append/)', + }); + assert.equal(result.ok, true); + assert.equal( + result.value.row, + '| 260811-gfl | Arreglar quick-tasks-append | 2026-08-11 | 0d3c644 | [260811-gfl-arreglar-quick-tasks-append](./quick/260811-gfl-arreglar-quick-tasks-append/) |', + ); + }); + + test('omitting quickId keeps the pre-existing ordinal + \'—\' Directory fallback (fast.md, #2133, unchanged)', () => { + const result = appendQuickTaskRow(emptyState, { + description: 'no id supplied', + date: '2026-08-11', + commit: 'abc1234', + }); + assert.equal(result.ok, true); + assert.ok(result.value.row.startsWith('| 1 | no id supplied |'), `expected the ordinal fallback, got: ${result.value.row}`); + assert.ok(result.value.row.endsWith('| — |'), `expected the '—' Directory fallback, got: ${result.value.row}`); + }); +}); + +describe('CLI: quick-tasks-append (#3356)', () => { + const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); + + /** Minimal STATE.md with a Quick Tasks table and a curated progress block. */ + function writeState(tmpDir, totalPhases) { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [ + '---', + 'progress:', + ` total_phases: ${totalPhases}`, + ' completed_phases: 3', + ' total_plans: ' + totalPhases, + ' completed_plans: 3', + ' percent: 12', + '---', + '', + '### Blockers/Concerns', + '', + '### Quick Tasks Completed', + '', + '| # | Description | Date | Commit | Directory |', + '|---|-------------|------|--------|-----------|', + '', + ].join('\n')); + } + + test('defect 1: --quick-id/--slug produce the canonical quick.md Step 7c row shape', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, 25); + + const r = runGsdTools( + ['quick-tasks-append', '--task', 'Fix thing', '--quick-id', '260811-gfl', '--slug', 'fix-thing'], + tmpDir, + ); + assert.ok(r.success, `quick-tasks-append should succeed: ${r.error}`); + const out = JSON.parse(r.output); + + assert.ok(out.row.startsWith('| 260811-gfl | Fix thing |'), `expected the '#' cell to carry the quick id, got: ${out.row}`); + assert.ok( + out.row.endsWith('| [260811-gfl-fix-thing](./quick/260811-gfl-fix-thing/) |'), + `expected the canonical directory permalink, got: ${out.row}`, + ); + + const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'); + assert.ok(stateContent.includes('| 260811-gfl | Fix thing |'), 'STATE.md itself must carry the same canonical row'); + }); + + test('bare --task (no id) keeps the fast.md-compatible ordinal + \'—\' row, unchanged', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeState(tmpDir, 25); + + const r = runGsdTools(['quick-tasks-append', '--task', 'Fix thing'], tmpDir); + assert.ok(r.success, `quick-tasks-append should succeed: ${r.error}`); + const out = JSON.parse(r.output); + assert.ok(/^\| 1 \| Fix thing \| .* \| — \|$/.test(out.row), `expected the ordinal + em-dash fallback, got: ${out.row}`); + }); + + test('defect 2: a body-only append does not force a full progress re-derive — a curated total_phases divergent from disk survives', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + // Curated progress (25) deliberately diverges from what a fresh disk scan + // would measure from the phases actually on disk (2) — the #3242 Bug A / + // #3356 defect 2 shape: a real project whose rollup total does not match + // this snapshot's own two phase dirs. + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-alpha'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-beta'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## Phase 1: Alpha\n## Phase 2: Beta\n'); + writeState(tmpDir, 25); + + const r = runGsdTools(['quick-tasks-append', '--task', 'Fix thing'], tmpDir); + assert.ok(r.success, `quick-tasks-append should succeed: ${r.error}`); + + const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8'); + const match = stateContent.match(/total_phases:\s*(\d+)/); + assert.equal( + match && match[1], '25', + `a body-only Quick Tasks append must not re-derive progress.total_phases from disk; ` + + `expected the curated 25 to survive, got: ${JSON.stringify(stateContent)}`, + ); + }); +}); + // ─── findTableBySchema (#2242 review Fix 4) ──────────────────────────────────── describe('findTableBySchema', () => { diff --git a/tests/milestone-lock.test.cjs b/tests/milestone-lock.test.cjs index 1ec3ccbe4..1ae00a6d1 100644 --- a/tests/milestone-lock.test.cjs +++ b/tests/milestone-lock.test.cjs @@ -33,6 +33,7 @@ const path = require('path'); const helpers = require('./helpers.cjs'); const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = helpers; const processSeam = require('./helpers/process-seam.cjs'); +const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); // runGsdTools's legacy shape drops stderr on success, but the #3311 contract // is exactly that a conflict is VISIBLE — these tests must see stderr. Drive @@ -631,9 +632,9 @@ describe('#3311 guard: advancePlan stays a targeted field replace', () => { const after = result.content; const section = (text) => { - const m = text.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); - assert.ok(m, 'Current Position section must exist'); - return m[1]; + const s = collectSection(text, (h) => /^Current Position$/i.test(h.text)); + assert.ok(s, 'Current Position section must exist'); + return s.body; }; const beforeSection = section(before); assert.ok(beforeSection.length > 0, 'before-section must be non-empty'); diff --git a/tests/model-omit-when-inherit-guard.test.cjs b/tests/model-omit-when-inherit-guard.test.cjs index 8dff1336d..22907dadd 100644 --- a/tests/model-omit-when-inherit-guard.test.cjs +++ b/tests/model-omit-when-inherit-guard.test.cjs @@ -359,7 +359,9 @@ test('#2684: ship.md validates capability-supplied ref.agent before it reaches a 'assignment IS the injection point (#2684 isolated review).', ); - const shape = new RegExp(gate[1]); + // Pattern extracted verbatim from ship.md's validation gate — the shipped + // regex IS the product under test (#3951). + const shape = new RegExp(gate[1]); // allow-adhoc-regex-escape: runtime-contract-is-the-product // Legitimate agent names the capability system actually dispatches. for (const ok of ['gsd-mempalace-curator', 'gsd-code-reviewer', 'my.agent_v2', 'a']) { diff --git a/tests/new-milestone-clear-phases.test.cjs b/tests/new-milestone-clear-phases.test.cjs index 12b9d80ff..c6ee6ab06 100644 --- a/tests/new-milestone-clear-phases.test.cjs +++ b/tests/new-milestone-clear-phases.test.cjs @@ -16,6 +16,19 @@ const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs'); const { runGsdTools, createTempProject, createTempGitProject, cleanup, readFileNormalized } = require('./helpers.cjs'); const { writeState } = require('./fixtures/index.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); + +/** Return the raw text of every ```bash fenced block in `content`. */ +function extractBashBlocks(content) { + const lines = content.split(/\r?\n/); + const blocks = []; + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + blocks.push(lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n')); + } + return blocks; +} describe('phases clear command', () => { let tmpDir; @@ -635,9 +648,9 @@ describe('new-milestone.md: workstream-aware PROJECT.md guard (#2308)', () => { assert.ok(endIdx !== -1, `heading not found: ${endHeading}`); assert.ok(startIdx < endIdx, `${startHeading} must precede ${endHeading}`); const section = markdown.slice(startIdx, endIdx); - const match = section.match(/```bash\r?\n([\s\S]*?)```/); - assert.ok(match, `no bash fence found between "${startHeading}" and "${endHeading}"`); - return match[1]; + const bashBlocks = extractBashBlocks(section); + assert.ok(bashBlocks.length > 0, `no bash fence found between "${startHeading}" and "${endHeading}"`); + return bashBlocks[0]; } // Step 6 has multiple ```bash fences; locate the one containing `marker`. @@ -646,10 +659,8 @@ describe('new-milestone.md: workstream-aware PROJECT.md guard (#2308)', () => { const endIdx = markdown.indexOf(endHeading); assert.ok(startIdx !== -1 && endIdx !== -1 && startIdx < endIdx, 'headings not found in order'); const section = markdown.slice(startIdx, endIdx); - const fenceRe = /```bash\r?\n([\s\S]*?)```/g; - let m; - while ((m = fenceRe.exec(section)) !== null) { - if (m[1].includes(marker)) return m[1]; + for (const block of extractBashBlocks(section)) { + if (block.includes(marker)) return block; } assert.fail(`no bash fence containing "${marker}" found between "${startHeading}" and "${endHeading}"`); return null; diff --git a/tests/pause-work-improvements.test.cjs b/tests/pause-work-improvements.test.cjs index fad4ae9f3..715a38d2d 100644 --- a/tests/pause-work-improvements.test.cjs +++ b/tests/pause-work-improvements.test.cjs @@ -92,6 +92,7 @@ const path = require('node:path'); const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); const { toLegacyResult } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup, readFileNormalized } = require('./helpers.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'resume-project.md'); @@ -113,9 +114,14 @@ function extractCheckBlock() { 'check_incomplete_work step must have a closing tag', ); const stepBody = md.slice(stepStart, stepEnd); - const fenceMatch = stepBody.match(/```(?:bash|sh)\r?\n([\s\S]*?)\r?\n```/); - assert.ok(fenceMatch, 'check_incomplete_work step must embed a ```bash code block'); - return fenceMatch[1]; + const stepLines = stepBody.split('\n'); + for (const block of scanFencedBlocks(stepLines)) { + if (block.closeLineIdx === -1) continue; + const info = (block.infoString || '').trim(); + if (info !== 'bash' && info !== 'sh') continue; + return stepLines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + } + assert.fail('check_incomplete_work step must embed a ```bash code block'); } function runSnippet(cwd, snippet) { diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index 7e439701f..d3611ffcd 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -25,6 +25,7 @@ const { toLegacyResult } = require('./helpers/git-fixture.cjs'); // above, and `run()` below at 15000ms for a lighter query-only call). const PHASE_COMPLETE_TIMEOUT_MS = 60000; const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); @@ -9628,6 +9629,7 @@ const { cleanup, runGsdTools } = require('./helpers.cjs'); // CJS implementation directly since that is where the bug lives. const phaseModule = require('../gsd-core/bin/lib/phase.cjs'); const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); const { cmdPhaseComplete } = phaseModule; function writePassedVerificationFile(phaseDir, phase = '01') { @@ -9752,15 +9754,19 @@ function roadmapCompletionSnapshot(roadmapContent) { continue; } - match = line.match(/^\|\s*(\d+[A-Z]?(?:\.\d+)*)\.?\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|\s*([^|]*)\|$/i); - if (match) { - snapshot.progressRows.push({ - phase: match[1].trim(), - title: match[2].trim(), - plans: match[3].trim(), - status: match[4].trim(), - completed: match[5].trim(), - }); + if (line.trim().startsWith('|')) { + const cells = splitTableRow(line); + const phaseTitleMatch = cells.length === 4 + && /^(\d+[A-Z]?(?:\.\d+)*)\.?\s*(.*)$/i.exec((cells[0] || '').trim()); + if (phaseTitleMatch) { + snapshot.progressRows.push({ + phase: phaseTitleMatch[1].trim(), + title: phaseTitleMatch[2].trim(), + plans: cells[1].trim(), + status: cells[2].trim(), + completed: cells[3].trim(), + }); + } } } @@ -10198,16 +10204,15 @@ describe('#3511: cmdPhaseComplete — advisory pre-scan warnings are phase-scope * 4-col (Phase | Plans | Status | Completed) or 5-col (Phase | Milestone | Plans | Status | Completed). */ function extractCompletedCell(roadmapContent, phaseNum) { - // Match the full progress table row whose first cell starts with the phase number. - // Use [^|\n] to avoid crossing line boundaries. Capture everything up to the final '|'. - const re = new RegExp(`^(\\|\\s*${phaseNum}[^|\\n]*(?:\\|[^|\\n]*)*)\\|\\s*$`, 'm'); - const m = roadmapContent.match(re); - if (!m) return null; - // m[1] = '| 01. Foundation | 1/1 | Complete | 2026-01-01 ' - // Split on '|' → ['', ' 01. Foundation ', ' 1/1 ', ' Complete ', ' 2026-01-01 '] - // Drop the leading empty string and take the last element. - const cells = m[1].split('|').slice(1); // drop leading '' - return cells[cells.length - 1].trim(); + // Find the progress table row whose first cell starts with the phase number. + for (const line of roadmapContent.split(/\r?\n/)) { + if (!line.trim().startsWith('|')) continue; + const cells = splitTableRow(line); + if (cells.length > 0 && cells[0].startsWith(String(phaseNum))) { + return cells[cells.length - 1].trim(); + } + } + return null; } /** @@ -11525,9 +11530,12 @@ describe('issue #2334: ghost-REQ-ID classification must probe write surfaces, no /-\s*\[x\]\s*\*\*KNOWN-01\*\*/i.test(reqContent), `#2334 HIGH 2b FAILED (fixture invariant): checkbox must have been ticked.\n${reqContent}`, ); + const traceabilityRow = reqContent.split(/\r?\n/) + .filter((l) => l.trim().startsWith('|')) + .map((l) => splitTableRow(l)) + .find((cells) => cells[0] && cells[0].trim().toLowerCase() === 'known-01'); assert.ok( - // eslint-disable-next-line local/no-unbounded-quantifier -- parses REQUIREMENTS.md the test itself wrote via build2334GhostSurfaceFixture, bounded fixed-size fixture, not adversarial input - /\|\s*KNOWN-01\s*\|[^|]*\|\s*Complete\s*\|/i.test(reqContent), + traceabilityRow && /^Complete$/i.test(traceabilityRow[traceabilityRow.length - 1].trim()), `#2334 HIGH 2b FAILED (fixture invariant): Traceability row must have flipped to Complete.\n${reqContent}`, ); assert.strictEqual(parsed.requirements_updated, true, '#2334 HIGH 2b FAILED: requirements_updated must be true'); diff --git a/tests/plan-checker-coupling.test.cjs b/tests/plan-checker-coupling.test.cjs index 1778d0bbd..b7151add3 100644 --- a/tests/plan-checker-coupling.test.cjs +++ b/tests/plan-checker-coupling.test.cjs @@ -32,6 +32,7 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { stripFencedCode } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.join(__dirname, '..'); const AGENT_PATH = path.join(ROOT, 'agents', 'gsd-plan-checker.md'); @@ -78,7 +79,7 @@ function countOrderedItems(span) { * uniqueness or completeness check built on it is wrong before it starts. */ function stripFences(content) { - return content.replace(/^```[\s\S]*?^```/gm, ''); + return stripFencedCode(content).text; } describe('gsd-plan-checker Dimension 3b — undeclared/temporal coupling (#1954)', () => { diff --git a/tests/plan-phase-ui-redirect.test.cjs b/tests/plan-phase-ui-redirect.test.cjs index 1d0193c1e..6ddc87d45 100644 --- a/tests/plan-phase-ui-redirect.test.cjs +++ b/tests/plan-phase-ui-redirect.test.cjs @@ -320,6 +320,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.join(__dirname, '..', 'gsd-core', 'workflows'); @@ -329,13 +330,13 @@ function read(rel) { function extractFindingsProbesFromBashBlocks(markdown) { const probes = []; - const fenceRe = /```bash\r?\n([\s\S]*?)```/g; - let fenceMatch; + const markdownLines = markdown.split(/\r?\n/); + const bashBlocks = scanFencedBlocks(markdownLines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash'); - while ((fenceMatch = fenceRe.exec(markdown)) !== null) { - const block = fenceMatch[1]; - const baseLine = markdown.slice(0, fenceMatch.index).split(/\r?\n/).length; - const lines = block.split(/\r?\n/); + for (const fenced of bashBlocks) { + const baseLine = fenced.openLineIdx + 1; + const lines = markdownLines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx); lines.forEach((line, idx) => { if (!line.includes('.claude/skills/')) return; diff --git a/tests/planner-language-regression.test.cjs b/tests/planner-language-regression.test.cjs index 89b8fad12..b0cc9ea15 100644 --- a/tests/planner-language-regression.test.cjs +++ b/tests/planner-language-regression.test.cjs @@ -20,6 +20,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); +const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.join(__dirname, '..'); const AGENTS_DIR = path.join(ROOT, 'agents'); @@ -291,9 +292,9 @@ describe('plan-phase.md — source audit orchestration (#2091)', () => { test('step 9b does not use "too complex" language', () => { // Extract just step 9b content (between "## 9b" and "## 9c" or "## 10") - const step9bMatch = workflowContent.match(/## 9b\.([\s\S]*?)(?=## 9c|## 10)/); - if (step9bMatch) { - const step9b = step9bMatch[1]; + const step9bSection = collectSection(workflowContent, (h) => h.text.startsWith('9b.')); + if (step9bSection) { + const step9b = step9bSection.body; assert.ok( !step9b.includes('too complex'), 'step 9b must not use "too complex" — use context budget language instead' diff --git a/tests/plugin-manifest.test.cjs b/tests/plugin-manifest.test.cjs index 9bb355c52..00368785d 100644 --- a/tests/plugin-manifest.test.cjs +++ b/tests/plugin-manifest.test.cjs @@ -347,7 +347,9 @@ describe('C: plugin.json schema validation', () => { const value = manifest[key]; if (propDef.pattern && typeof value === 'string') { - const re = new RegExp(propDef.pattern); + // Pattern extracted verbatim from the schema fixture — the shipped + // JSON-schema pattern IS the product under test (#3951). + const re = new RegExp(propDef.pattern); // allow-adhoc-regex-escape: runtime-contract-is-the-product if (!re.test(value)) { errors.push(`"${key}" must match ${propDef.pattern}, got "${value}"`); } diff --git a/tests/pr-branch-planning-filter.test.cjs b/tests/pr-branch-planning-filter.test.cjs index 67ba55177..62fb0b7ad 100644 --- a/tests/pr-branch-planning-filter.test.cjs +++ b/tests/pr-branch-planning-filter.test.cjs @@ -211,7 +211,9 @@ describe('#2971 — pr-branch.md planning.pr_strict filter (failing-first)', () }); test('15: every structural file is not forbidden default, forbidden strict', () => { - const structuralRe = new RegExp(fixture.structuralRe); + // Pattern extracted verbatim from the workflow fixture — the shipped + // STRUCTURAL_RE pattern IS the product under test (#3951). + const structuralRe = new RegExp(fixture.structuralRe); // allow-adhoc-regex-escape: runtime-contract-is-the-product for (const name of ['STATE', 'ROADMAP', 'MILESTONES', 'PROJECT', 'REQUIREMENTS']) { const p = `.planning/${name}.md`; assert.ok(structuralRe.test(p), `fixture bug: STRUCTURAL_RE must accept ${p}`); diff --git a/tests/prohibition-probe.docs-fixtures.test.cjs b/tests/prohibition-probe.docs-fixtures.test.cjs index e08e5ca0f..9712a29c3 100644 --- a/tests/prohibition-probe.docs-fixtures.test.cjs +++ b/tests/prohibition-probe.docs-fixtures.test.cjs @@ -17,6 +17,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const docPath = path.join(__dirname, '..', 'gsd-core', 'references', 'prohibition-probe.md'); const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'prohibition-probe-fixtures'); @@ -24,10 +25,15 @@ const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'prohi // Extract fenced blocks tagged ```json prohibition-probe:/ from the doc, keyed by ref. // The \n? before the closing fence allows blocks whose closing fence has no preceding newline. function taggedJsonBlocks(md) { - const re = /```json prohibition-probe:([^\r\n]+)\r?\n([\s\S]*?)\r?\n?```/g; + const lines = md.split(/\r?\n/); const out = {}; - let m; - while ((m = re.exec(md))) out[m[1].trim()] = m[2]; + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + const info = block.infoString || ''; + const tagMatch = /^json prohibition-probe:([^\r\n]+)$/.exec(info); + if (!tagMatch) continue; + out[tagMatch[1].trim()] = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + } return out; } diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index fa5e015b7..9f2495d3b 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -20,6 +20,7 @@ function sha256(content) { } const { cleanup } = require('./helpers.cjs'); +const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); function createTempDir() { return fs.mkdtempSync(path.join(require('os').tmpdir(), 'gsd-patch-test-')); @@ -382,10 +383,9 @@ describe('reapply-patches gated hunk verification (#1999)', () => { // assert it both names the table and defines an explicit gate // condition tied to the `verified` column. const content = fs.readFileSync(workflowPath, 'utf8'); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored reapply-patches.md workflow, bounded prose, not adversarial input - const step5Match = content.match(/^##\s+Step 5[^\r\n]*\r?\n([\s\S]*?)(?=^##\s|Z)/m); - assert.ok(step5Match, 'reapply-patches workflow must contain a "## Step 5" section'); - const step5 = step5Match[1]; + const step5Section = collectSection(content, (h) => /^Step 5\b/.test(h.text)); + assert.ok(step5Section, 'reapply-patches workflow must contain a "## Step 5" section'); + const step5 = step5Section.body; assert.ok( /Hunk Verification Table/.test(step5), 'Step 5 body must explicitly reference the Hunk Verification Table' @@ -407,10 +407,9 @@ describe('reapply-patches gated hunk verification (#1999)', () => { test('Step 5 also halts when the Hunk Verification Table is absent (Step 4 produced nothing)', () => { // Independent gate: missing-table is a separate halt path from any-no-row. const content = fs.readFileSync(workflowPath, 'utf8'); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses maintainer-authored reapply-patches.md workflow, bounded prose, not adversarial input - const step5Match = content.match(/^##\s+Step 5[^\r\n]*\r?\n([\s\S]*?)(?=^##\s|Z)/m); - assert.ok(step5Match, 'Step 5 section must exist'); - const step5 = step5Match[1]; + const step5Section = collectSection(content, (h) => /^Step 5\b/.test(h.text)); + assert.ok(step5Section, 'Step 5 section must exist'); + const step5 = step5Section.body; const handlesAbsent = /(table is absent|table is missing|missing.*table|absent.*table)/i.test(step5); assert.ok( handlesAbsent, diff --git a/tests/reversibility-tagging.test.cjs b/tests/reversibility-tagging.test.cjs index 1245248d0..8ce32a5ba 100644 --- a/tests/reversibility-tagging.test.cjs +++ b/tests/reversibility-tagging.test.cjs @@ -14,6 +14,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); +const { scanFencedBlocks, collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.resolve(__dirname, '..'); const PLANNER = path.join(ROOT, 'agents', 'gsd-planner.md'); @@ -55,7 +56,16 @@ function namesRating(text, rating) { /** The fenced ```bash blocks of a workflow file, so prose cannot satisfy a * test that claims to assert on the parser. */ function bashBlocks(md) { - return [...md.matchAll(/```bash\r?\n([\s\S]*?)```/g)].map((m) => m[1]); + const lines = md.split(/\r?\n/); + return scanFencedBlocks(lines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') + .map((b) => lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n')); +} + +/** The body of the "## N. Reversibility Test" section (heading excluded), or '' if absent. */ +function reversibilityTestSection(md) { + const section = collectSection(md, (h) => /^\d+\. Reversibility Test\b/.test(h.text)); + return section ? section.body : ''; } // ─── Acceptance #1: discuss-phase decisions carry a rating + rationale ─────── @@ -452,7 +462,7 @@ describe('#1951 ungated one-way rating is flagged', () => { describe('#1951 taxonomy parity: a single three-level vocabulary', () => { test('the Reversibility Test thinking model uses the canonical three ratings', () => { const tm = read(THINKING_MODELS); - const section = (tm.match(/## \d+\. Reversibility Test[\s\S]*?(?=\n## |$)/) || [''])[0]; + const section = reversibilityTestSection(tm); assert.ok(section.length > 0, 'thinking-models-planning.md must retain a Reversibility Test model'); for (const rating of RATINGS) { assert.ok( @@ -463,7 +473,7 @@ describe('#1951 taxonomy parity: a single three-level vocabulary', () => { }); test('the legacy binary IRREVERSIBLE vocabulary is gone', () => { - const section = (read(THINKING_MODELS).match(/## \d+\. Reversibility Test[\s\S]*?(?=\n## |$)/) || [''])[0]; + const section = reversibilityTestSection(read(THINKING_MODELS)); assert.ok( !/IRREVERSIBLE/.test(section), 'the binary REVERSIBLE/IRREVERSIBLE vocabulary must be replaced by the three-level taxonomy, ' @@ -472,7 +482,7 @@ describe('#1951 taxonomy parity: a single three-level vocabulary', () => { }); test('thinking model points at the canonical taxonomy owner', () => { - const section = (read(THINKING_MODELS).match(/## \d+\. Reversibility Test[\s\S]*?(?=\n## |$)/) || [''])[0]; + const section = reversibilityTestSection(read(THINKING_MODELS)); assert.ok( section.includes('planner-reversibility.md'), 'the thinking model must point at planner-reversibility.md as the taxonomy owner', diff --git a/tests/review-build-prompt-optional-sections.test.cjs b/tests/review-build-prompt-optional-sections.test.cjs index 96c096985..7057e460e 100644 --- a/tests/review-build-prompt-optional-sections.test.cjs +++ b/tests/review-build-prompt-optional-sections.test.cjs @@ -40,6 +40,7 @@ const { readWorkflowCombined, } = require('./helpers.cjs'); const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const REVIEW_WORKFLOW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'review.md'); @@ -95,10 +96,10 @@ function extractBuildPromptBlock() { /** Every fenced ```bash block of review.md (+steps), for the structural row. */ function extractAllBashBlocks() { const content = readWorkflowCombined(REVIEW_WORKFLOW); - const blocks = []; - const re = /```bash\r?\n([\s\S]*?)\r?\n```/g; - let m; - while ((m = re.exec(content)) !== null) blocks.push(m[1]); + const lines = content.split(/\r?\n/); + const blocks = scanFencedBlocks(lines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') + .map((b) => lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n')); assert.ok(blocks.length > 0, 'no ```bash blocks found in review.md (+steps)'); return blocks; } diff --git a/tests/review-parallel-lanes.test.cjs b/tests/review-parallel-lanes.test.cjs index 054738828..393239984 100644 --- a/tests/review-parallel-lanes.test.cjs +++ b/tests/review-parallel-lanes.test.cjs @@ -30,6 +30,7 @@ const { const { runHook } = require('./helpers/process-seam.cjs'); const { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { toPosixPath } = require('../gsd-core/bin/lib/shell-command-projection.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const REPO_ROOT = path.join(__dirname, '..'); const REVIEW_MD_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'review.md'); @@ -64,12 +65,14 @@ function extractInvokeReviewersBash() { } const stepBody = afterStep.slice(0, endIdx); - const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/; - const fenceMatch = fenceRe.exec(stepBody); - if (!fenceMatch) { + const stepBodyLines = stepBody.split(/\r?\n/); + const fenced = scanFencedBlocks(stepBodyLines).find( + (b) => b.closeLineIdx !== -1 && ['bash', 'sh'].includes((b.infoString || '').trim()), + ); + if (!fenced) { throw new Error(`extractInvokeReviewersBash: no \`\`\`bash fence found inside invoke_reviewers step in ${REVIEW_MD_PATH}`); } - const block = fenceMatch[1]; + const block = stepBodyLines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx).join('\n'); if (!block.trim()) { throw new Error('extractInvokeReviewersBash: extracted bash block is empty'); @@ -637,10 +640,11 @@ function extractStepBody(stepName) { /** Finds the first ```bash/```sh fence in `stepBody` whose text contains every string in `mustInclude`. */ function extractBashFenceContaining(stepBody, mustInclude, label) { - const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/g; - let m; - while ((m = fenceRe.exec(stepBody)) !== null) { - const block = m[1]; + const stepBodyLines = stepBody.split(/\r?\n/); + for (const fenced of scanFencedBlocks(stepBodyLines)) { + if (fenced.closeLineIdx === -1) continue; + if (!['bash', 'sh'].includes((fenced.infoString || '').trim())) continue; + const block = stepBodyLines.slice(fenced.openLineIdx + 1, fenced.closeLineIdx).join('\n'); if (mustInclude.every((s) => block.includes(s))) return block; } throw new Error(`extractBashFenceContaining: no fence matching ${label} found (looked for ${JSON.stringify(mustInclude)})`); diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 82a6117a9..b5e13a19d 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -11,6 +11,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); describe('roadmap get-phase command', () => { let tmpDir; @@ -1953,9 +1954,12 @@ describe('bug #2661: execute-plan.md update_roadmap gating', () => { // The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block, // i.e. it must NOT be unconditional and it must NOT appear on the worktree branch. // We verify by extracting the bash block and checking the call sits under the gate. - const bashMatch = step.match(/```bash\s*([\s\S]*?)```/); - assert.ok(bashMatch, 'update_roadmap must contain a bash block'); - const bash = bashMatch[1]; + const stepLines = step.split(/\r?\n/); + const bashFence = scanFencedBlocks(stepLines).find( + (b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash', + ); + assert.ok(bashFence, 'update_roadmap must contain a bash block'); + const bash = stepLines.slice(bashFence.openLineIdx + 1, bashFence.closeLineIdx).join('\n'); assert.ok( /IS_WORKTREE/.test(bash), diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index 5905f8471..745a5cad0 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -1579,10 +1579,23 @@ test('manager.md and autonomous.md no longer contain old "not claude" background // script, so the harness is written to a file rather than passed as `-c`. const { runHook } = require('./helpers/process-seam.cjs'); const { readFileNormalized, createTempDir, cleanup: cleanupDir } = require('./helpers.cjs'); + const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); // Skipped on Windows, where there is no bash. Checked by platform rather than // by shelling out to `which`, which is itself non-portable. const NO_BASH = process.platform === 'win32'; + /** The first ```bash fenced block in `src` whose body includes `marker`. */ + function bashBlockContaining(src, marker) { + const lines = src.split(/\r?\n/); + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + const body = lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n'); + if (body.includes(marker)) return body; + } + return undefined; + } + describe('#2486 regression: settings/health worktrees isolation branch', () => { // Review round 2 (#2584 Phase 3): isolation is a DECLARED CAPABILITY, not a // runtime name. cursor declares harness-worktree and codex/opencode/kimi/ @@ -1799,9 +1812,7 @@ test('manager.md and autonomous.md no longer contain old "not claude" background const src = readFileNormalized( path.join(__dirname, '..', 'gsd-core', 'workflows', 'health.md'), ); - const block = [...src.matchAll(/```bash\r?\n([\s\S]*?)```/g)] - .map(m => m[1]) - .find(b => b.includes('W025:')); + const block = bashBlockContaining(src, 'W025:'); assert.ok(block, 'health.md: no ```bash block containing the W025 diagnostic'); /** Run the shipped block with `gsd_run` stubbed to the given answers. */ @@ -1872,9 +1883,7 @@ test('manager.md and autonomous.md no longer contain old "not claude" background const src = readFileNormalized( path.join(__dirname, '..', 'gsd-core', 'workflows', 'health.md'), ); - const block = [...src.matchAll(/```bash\r?\n([\s\S]*?)```/g)] - .map(m => m[1]) - .find(b => b.includes('W025:')); + const block = bashBlockContaining(src, 'W025:'); assert.ok(block, 'health.md: no ```bash block containing the W025 diagnostic'); // `resolves` false = the inspect call exits non-zero, the real shape of a diff --git a/tests/runtime-marker-resolution.test.cjs b/tests/runtime-marker-resolution.test.cjs index ccb42a427..65039697f 100644 --- a/tests/runtime-marker-resolution.test.cjs +++ b/tests/runtime-marker-resolution.test.cjs @@ -104,7 +104,7 @@ function withEnv(overrides, fn) { } describe('per-install .gsd-runtime marker rung in the canonical resolver (#3897 rung 2)', () => { - test('T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897', (t) => { + test('T3 installMarkerResolvesWhenEnvAndConfigAbsent_3897 (#3364)', (t) => { const rs = requireRuntimeSlash(); assertMarkerSeamExists(rs); const proj = neutralProject(t); diff --git a/tests/ship-notes-wedged-pr.test.cjs b/tests/ship-notes-wedged-pr.test.cjs index 915031b0a..15c4c363e 100644 --- a/tests/ship-notes-wedged-pr.test.cjs +++ b/tests/ship-notes-wedged-pr.test.cjs @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); -const { stripFencedCode } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); +const { stripFencedCode, scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const { cleanup, createTempDir, readFileNormalized } = require('./helpers.cjs'); const SHIP_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md'); @@ -20,10 +20,13 @@ function extractStep(name) { function extractTrackShippingScript() { const step = extractStep('track_shipping'); - const blocks = [...step.matchAll(/```bash\r?\n([\s\S]*?)\r?\n```/g)]; - const match = blocks.find(block => block[1].includes('mergeStateStatus')); + const lines = step.split(/\r?\n/); + const blocks = scanFencedBlocks(lines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') + .map((b) => lines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n')); + const match = blocks.find(block => block.includes('mergeStateStatus')); assert.ok(match, 'track_shipping must contain an executable merge-state bash block'); - return match[1]; + return match; } function runTrackShipping(responses) { diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 1fac4ae91..d9c3cf37c 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -2820,7 +2820,10 @@ const TEMPLATE_PATHS = [ * @returns {string} The extracted code block body. */ function extractFileTemplate(fileContent) { - const match = fileContent.match(/```markdown\r?\n([\s\S]*?)```/); + // Deliberately independent of the generator's own fence-handling — this is + // the bug #21 regression guard that must not share the seam + // gen-state-md-docs.cjs uses (see tests/gen-state-md-docs.test.cjs). + const match = fileContent.match(/```markdown\r?\n([\s\S]*?)```/); // allow-adhoc-markdown: deliberately independent of the generator's fence-handling — regresses bug #21 assert.ok(match, 'No ```markdown code block found in template file'); return match[1]; } diff --git a/tests/state.test.cjs b/tests/state.test.cjs index fc90f26c3..8175bb5a9 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -37,6 +37,40 @@ const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); const frontmatterLib = require('../gsd-core/bin/lib/frontmatter.cjs'); const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs'); const workstreamInventory = require('../gsd-core/bin/lib/workstream-inventory.cjs'); +const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); + +/** + * Test-side helper mirroring the ad-hoc "## Heading ... up to next heading" + * extraction previously hand-rolled at many call sites in this file — routes + * through the canonical markdown-sectionizer seam instead. Returns an object + * shaped like a regex exec match (`[1]` is the body) so existing call sites + * that destructure `match[1]` keep working, or `null` when the heading is + * absent (matching `String.prototype.match`'s null-on-no-match contract). + */ +function sectionMatchOf(text, headingName) { + const section = collectSection(text, (h) => h.text.toLowerCase() === headingName.toLowerCase()); + return section ? [section.body, section.body] : null; +} + +/** + * Return the second cell of a headerless `| Label | Value |` row inside + * `section` whose first cell equals `label` (case-insensitive), or null when + * absent. STATE.md's Current Position/Configuration tables have no header/ + * delimiter row, so parseMarkdownTable's GFM-table contract does not apply — + * splitTableRow is the correct-granularity seam call here. + */ +function pipeTableCell(section, label) { + for (const line of section.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + const cells = splitTableRow(trimmed); + if (cells[0] && cells[0].toLowerCase() === label.toLowerCase()) { + return cells[1] !== undefined ? cells[1] : null; + } + } + return null; +} // Phase 12 (#3310, ADR-3180 §8.4 rule 3): `cmdStateValidate`'s `warnings` are // now `Diagnostic[]` (S0NN codes), not bare strings, and `drift` is gone. const { SEVERITY } = require('../gsd-core/bin/lib/health-diagnostic-types.cjs'); @@ -113,7 +147,7 @@ function readShippedStateTemplateBody(replacements) { const templatePath = path.join(__dirname, '..', 'gsd-core', 'templates', 'state.md'); const template = fs.readFileSync(templatePath, 'utf-8'); // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own state.md template, fixed-size author-controlled content - const fencedDocument = template.match(/```markdown\r?\n([\s\S]*?)```/); + const fencedDocument = template.match(/```markdown\r?\n([\s\S]*?)```/); // allow-adhoc-markdown: deliberately independent of the generator's own fence-handling — regresses #3873 assert.ok(fencedDocument, 'gsd-core/templates/state.md must contain a fenced markdown document'); let body = fencedDocument[1]; @@ -2581,8 +2615,7 @@ describe('cmdStateResolveBlocker (state resolve-blocker)', () => { assert.ok(!updated.includes('- Single blocker'), 'resolved blocker should be removed'); // Section should contain "None" placeholder, not be empty - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const sectionMatch = updated.match(/## Blockers\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const sectionMatch = sectionMatchOf(updated, 'Blockers'); assert.ok(sectionMatch, 'Blockers section should still exist'); assert.ok(sectionMatch[1].includes('None'), 'Blockers section should contain None placeholder'); }); @@ -2985,8 +3018,7 @@ Progress: [..........] 0% ); // Extract the Current Position section - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const posMatch = sectionMatchOf(content, 'Current Position'); assert.ok(posMatch, 'Current Position section should exist'); const posSection = posMatch[1]; @@ -3087,8 +3119,7 @@ Progress: [..........] 0% const content = fs.readFileSync( path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8' ); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const posMatch = sectionMatchOf(content, 'Current Position'); assert.ok(posMatch, 'Current Position section should exist after advance-plan'); const posSection = posMatch[1]; @@ -4205,8 +4236,7 @@ Progress: [##########] 20% ); // Current Position Status: line must also be "Ready to execute" - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const posMatch = sectionMatchOf(stateContent, 'Current Position'); assert.ok(posMatch, 'Current Position section not found'); const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m); assert.ok(posStatusMatch, 'Status field not found in Current Position section'); @@ -4261,8 +4291,7 @@ Progress: [##########] 20% const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); // Locate the Current Position section and verify the Status line there. - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const posMatch = sectionMatchOf(stateContent, 'Current Position'); assert.ok(posMatch, 'Current Position section not found'); const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m); assert.ok(posStatusMatch, 'Status field not found in Current Position section'); @@ -8132,7 +8161,7 @@ describe('state add-roadmap-evolution (bug #1140)', () => { // Body of `## Accumulated Context` bounded by the next h2 (or EOF), so // placement assertions prove a subsection sits INSIDE that section. const accumulatedContextBody = (state) => { - const m = state.match(/##\s*Accumulated Context\s*\r?\n([\s\S]*?)(?=\n##[^#]|$)/); + const m = sectionMatchOf(state, 'Accumulated Context'); return m ? m[1] : null; }; @@ -9048,8 +9077,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); // Extract the ## Current Position section only, to avoid matching Configuration rows - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const cpMatch = sectionMatchOf(after, 'Current Position'); assert.ok(cpMatch, '## Current Position section must exist'); const cpSection = cpMatch[1]; @@ -9061,8 +9089,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' // Last activity cell must include date + narrative (not bare date) assert.ok( - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md generated by the tool under test against a bounded fixture project, not adversarial input - /\|\s*Last activity\s*\|[^|]*—\s*Phase 1 execution started\s*\|/i.test(cpSection), + /—\s*Phase 1 execution started\s*$/i.test(pipeTableCell(cpSection, 'Last activity') || ''), `Current Position Last activity cell must include narrative '— Phase 1 execution started'; got Current Position:\n${cpSection}` ); } finally { @@ -9125,8 +9152,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); // Extract the ## Current Position section only, to avoid matching Configuration rows - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const cpMatch = sectionMatchOf(after, 'Current Position'); assert.ok(cpMatch, '## Current Position section must exist'); const cpSection = cpMatch[1]; @@ -9148,8 +9174,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md' // Bug 2: Last activity cell must include date + narrative (not bare date) assert.ok( - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md generated by the tool under test against a bounded fixture project, not adversarial input - /\|\s*Last activity\s*\|[^|]*—\s*Phase 1 marked complete\s*\|/i.test(cpSection), + /—\s*Phase 1 marked complete\s*$/i.test(pipeTableCell(cpSection, 'Last activity') || ''), `Current Position Last activity cell must include narrative '— Phase 1 marked complete'; got Current Position:\n${cpSection}` ); } finally { @@ -9297,8 +9322,7 @@ describe('#1257 — planned-phase and begin-phase pipe-table regressions', () => // Extract the ## Configuration section (stops before ## Current Position) // to avoid false-positive from the Current Position table (which IS updated // by updateCurrentPositionFields). - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const cfgMatch = after.match(/##\s*Configuration\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const cfgMatch = sectionMatchOf(after, 'Configuration'); assert.ok(cfgMatch, '## Configuration section must exist'); const cfgSection = cfgMatch[1]; @@ -9355,15 +9379,13 @@ describe('#1257 — planned-phase and begin-phase pipe-table regressions', () => const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); // Extract ## Current Position section only - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const cpMatch = sectionMatchOf(after, 'Current Position'); assert.ok(cpMatch, '## Current Position section must exist'); const cpSection = cpMatch[1]; // The pipe-table Phase cell must be updated to reflect the executing phase assert.ok( - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md generated by the tool under test against a bounded fixture project, not adversarial input - /\|\s*Phase\s*\|[^|]*1[^|]*EXECUTING[^|]*\|/i.test(cpSection), + /1[\s\S]*EXECUTING/i.test(pipeTableCell(cpSection, 'Phase') || ''), `Current Position pipe-table Phase cell must contain phase 1 EXECUTING; got Current Position:\n${cpSection}` ); @@ -9391,8 +9413,7 @@ describe('#1257 — planned-phase and begin-phase pipe-table regressions', () => const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); // Extract ## Current Position section only - // eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test just wrote via a fixture, fixed-size test-controlled content - const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i); + const cpMatch = sectionMatchOf(after, 'Current Position'); assert.ok(cpMatch, '## Current Position section must exist'); const cpSection = cpMatch[1]; diff --git a/tests/tracer-bullet.test.cjs b/tests/tracer-bullet.test.cjs index fa5bbf2b0..34c18f38e 100644 --- a/tests/tracer-bullet.test.cjs +++ b/tests/tracer-bullet.test.cjs @@ -24,6 +24,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const ROOT = path.join(__dirname, '..'); const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf-8'); @@ -778,10 +779,13 @@ describe('#3299 regression: tracer feedback gate honors workflow.human_verify_mo 'the first non-blank line after the tracer task shape marker must be a LIVE ```xml fence opener — ' + 'a commented-out or non-adjacent decoy template must not be selectable', ); - const after = lines.slice(openIdx).join('\n'); - const fence = after.match(/```xml[^\r\n]*\r?\n([\s\S]*?)```/); - assert.ok(fence, 'the tracer task shape must be followed by a fenced xml block'); - const verifies = fence[1].match(/[\s\S]*?<\/verify>/g) || []; + const afterLines = lines.slice(openIdx); + const xmlFence = scanFencedBlocks(afterLines).find( + (b) => b.closeLineIdx !== -1 && /^xml(?:\s.*)?$/.test((b.infoString || '').trim()), + ); + assert.ok(xmlFence, 'the tracer task shape must be followed by a fenced xml block'); + const fenceBody = afterLines.slice(xmlFence.openLineIdx + 1, xmlFence.closeLineIdx).join('\n'); + const verifies = fenceBody.match(/[\s\S]*?<\/verify>/g) || []; assert.strictEqual(verifies.length, 1, `the tracer template must contain exactly ONE , found ${verifies.length}`); const inner = verifies[0].replace(/^/, '').replace(/<\/verify>$/, '').replace(/\s+/g, ' ').trim(); assert.match(inner, /^[^<>]+<\/automated>$/, diff --git a/tests/ui-review-next-guidance.test.cjs b/tests/ui-review-next-guidance.test.cjs index 09e58ee6a..22c0b5702 100644 --- a/tests/ui-review-next-guidance.test.cjs +++ b/tests/ui-review-next-guidance.test.cjs @@ -92,7 +92,9 @@ describe('manager verify dispatch', () => { assert.match(compoundBlock, /gsd-verify-work/); assert.doesNotMatch( compoundBlock, - /Inline verification:\s*```[\s\S]*Skill\(skill="gsd-verify-work", args="\{PHASE_NUM\}"\)/, + // Not a fence PARSE — a negative-text probe asserting a banned prose + // pattern is absent; there is no fence content being extracted. + /Inline verification:\s*```[\s\S]*Skill\(skill="gsd-verify-work", args="\{PHASE_NUM\}"\)/, // allow-adhoc-markdown: negative-text probe, not a fence parse ); }); }); diff --git a/tests/ui-spec-inventory-provenance.test.cjs b/tests/ui-spec-inventory-provenance.test.cjs index fe0d46919..7dde4f443 100644 --- a/tests/ui-spec-inventory-provenance.test.cjs +++ b/tests/ui-spec-inventory-provenance.test.cjs @@ -44,6 +44,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const fc = require('fast-check'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); const ROOT = path.join(__dirname, '..'); @@ -113,7 +114,9 @@ function parseSignOff(text) { function parseReturnTableRows(text) { const seen = new Map(); for (const line of lf(text).split('\n')) { - const m = /^\|\s*(\d+)\s+([^|]+?)\s*\|/.exec(line); + if (!line.trim().startsWith('|')) continue; + const cells = splitTableRow(line); + const m = cells.length > 0 ? /^(\d+)\s+(\S.*?)\s*$/.exec(cells[0]) : null; if (m) seen.set(`${m[1]}|${m[2]}`, { n: Number(m[1]), label: m[2] }); } return [...seen.values()]; diff --git a/tests/verification-status.test.cjs b/tests/verification-status.test.cjs index 66ab24742..f14514ada 100644 --- a/tests/verification-status.test.cjs +++ b/tests/verification-status.test.cjs @@ -42,6 +42,7 @@ const os = require('node:os'); const { cleanup } = require('./helpers.cjs'); const { runGit: seamRunGit, OUTCOME } = require('./helpers/process-seam.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const { VERIFIER_STATUSES, @@ -1864,8 +1865,10 @@ describe('#2868: verification status CLI drives the execute-phase stranded-phase // the jq form in order to explain why it is not used, and an assertion // over the whole file would fire on its own rationale. const content = fs.readFileSync(QUICK_VERIFICATION, 'utf-8'); - // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content - const fences = content.match(/```bash\r?\n[\s\S]*?```/g) || []; + const contentLines = content.split(/\r?\n/); + const fences = scanFencedBlocks(contentLines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') + .map((b) => contentLines.slice(b.openLineIdx, b.closeLineIdx + 1).join('\n')); const statusFence = fences.find((f) => f.includes('gsd_run query verification.status')); assert.ok(statusFence, 'the status read must live in a bash fence'); diff --git a/tests/verifier-behavior-unverified.test.cjs b/tests/verifier-behavior-unverified.test.cjs index 2cafa65c7..76cd7d4bd 100644 --- a/tests/verifier-behavior-unverified.test.cjs +++ b/tests/verifier-behavior-unverified.test.cjs @@ -147,6 +147,8 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); +const { findTableWithColumns } = require('../gsd-core/bin/lib/markdown-table.cjs'); const REPO_ROOT = path.join(__dirname, '..'); const VERIFIER_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-verifier.md'); @@ -158,16 +160,23 @@ function verifierProbeContract(content) { assert.notEqual(sectionEnd, -1, 'verifier must close Step 7c before Step 8'); const section = content.slice(sectionStart, sectionEnd); - const codeBlocks = [...section.matchAll(/```bash\r?\n([\s\S]*?)\r?\n```/g)].map((match) => match[1].split(/\r?\n/).join('\n')); + const sectionLines = section.split(/\r?\n/); + const codeBlocks = scanFencedBlocks(sectionLines) + .filter((b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash') + .map((b) => sectionLines.slice(b.openLineIdx + 1, b.closeLineIdx).join('\n')); const executionSteps = [...section.matchAll(/^\d+\.\s+(.+)$/gm)].map((match) => match[1]); + const probeTable = findTableWithColumns(section, ['Probe', 'Command', 'Result', 'Status']); return { title: 'Step 7c: Probe Execution', conventionalDiscoveryCommand: codeBlocks[0]?.split('\n').find((line) => line.startsWith('find scripts')) || null, declaredDiscoveryCommand: codeBlocks[0]?.split('\n').find((line) => line.startsWith('grep -R')) || null, executionCommand: codeBlocks[1] || '', executionSteps, - statusRows: [...section.matchAll(/^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|[^|]+\|\s*([^|]+)\|$/gm)] - .map((match) => ({ probe: match[1], command: match[2], statuses: match[3].trim() })), + statusRows: (probeTable ? probeTable.rows : []).map((row) => ({ + probe: row.Probe.replace(/^`|`$/g, ''), + command: row.Command.replace(/^`|`$/g, ''), + statuses: row.Status.trim(), + })), summaryClaimsRejected: section.includes('SUMMARY.md probe pass claims are not evidence'), }; } diff --git a/tests/verifier-coincidental-reliance.test.cjs b/tests/verifier-coincidental-reliance.test.cjs index 43c156a44..734b517dd 100644 --- a/tests/verifier-coincidental-reliance.test.cjs +++ b/tests/verifier-coincidental-reliance.test.cjs @@ -40,6 +40,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); const ROOT = path.join(__dirname, '..'); @@ -180,7 +181,9 @@ describe('#1955: coincidental-reliance advisory — the invariants', () => { // substring still hits. A bare `(coincidental-reliance)` verdict cell, or // any form that puts the qualifier before the token, is a break. for (const source of [verifier, template]) { - const verdictCells = source.match(/\|\s*[^|\n]*coincidental-reliance[^|\n]*\|/g) || []; + const verdictCells = source.split(/\r?\n/) + .filter((line) => line.includes('|') && line.includes('coincidental-reliance')) + .flatMap((line) => splitTableRow(line).filter((cell) => cell.includes('coincidental-reliance'))); for (const cell of verdictCells) { assert.ok( cell.includes(QUALIFIER), diff --git a/tests/workflow-jq-dependency.test.cjs b/tests/workflow-jq-dependency.test.cjs index bfdb99bc5..06766f0d8 100644 --- a/tests/workflow-jq-dependency.test.cjs +++ b/tests/workflow-jq-dependency.test.cjs @@ -57,7 +57,7 @@ describe('#2589: config/model/verify lookups do not depend on jq', () => { test('config-get lookups use --raw, not a jq pipe', () => { // Matches: config-get ... | jq (any key, any jq program). // The native --raw flag strips JSON quotes off a scalar with no jq. - const re = /config-get\b[^|\n]*\|\s*jq\b/; + const re = /config-get\b[^|\n]*\|\s*jq\b/; // allow-adhoc-markdown: shell-pipe-to-jq detector, not a markdown table scan for (const name of AUDITED) { const content = readWorkflow(name); if (content == null) continue; @@ -72,7 +72,7 @@ describe('#2589: config/model/verify lookups do not depend on jq', () => { test('resolve-model lookups use --pick, not a jq pipe', () => { // resolve-model returns an object; the native --pick descends it. - const re = /resolve-model\b[^|\n]*\|\s*jq\b/; + const re = /resolve-model\b[^|\n]*\|\s*jq\b/; // allow-adhoc-markdown: shell-pipe-to-jq detector, not a markdown table scan for (const name of AUDITED) { const content = readWorkflow(name); if (content == null) continue; @@ -86,7 +86,7 @@ describe('#2589: config/model/verify lookups do not depend on jq', () => { }); test('verification.status lookups use --pick, not a jq pipe', () => { - const re = /verification\.status\b[^|\n]*\|\s*jq\b/; + const re = /verification\.status\b[^|\n]*\|\s*jq\b/; // allow-adhoc-markdown: shell-pipe-to-jq detector, not a markdown table scan for (const name of AUDITED) { const content = readWorkflow(name); if (content == null) continue; @@ -103,7 +103,7 @@ describe('#2589: config/model/verify lookups do not depend on jq', () => { // Belt-and-suspenders: the jq-replaceable query command families. This // catches a future regression on any of them (or a sibling like // resolve-execution, which also supports --pick) without enumerating keys. - const re = /gsd_run\s+query\s+(config-get|resolve-model|resolve-execution|verification\.status\b)[^|\n]*\|\s*jq\b/; + const re = /gsd_run\s+query\s+(config-get|resolve-model|resolve-execution|verification\.status\b)[^|\n]*\|\s*jq\b/; // allow-adhoc-markdown: shell-pipe-to-jq detector, not a markdown table scan for (const name of AUDITED) { const content = readWorkflow(name); if (content == null) continue; @@ -194,7 +194,7 @@ describe('#2589: config/model/verify lookups do not depend on jq', () => { // loudly — it silently reproduces the fresh-install fallback // (INSTALLED_VERSION=0.0.0, scope UNKNOWN), so `/gsd:update` re-installs // over a working install and targets the wrong runtime directory. - const re = /update-context\b[^|\n]*\|\s*jq\b/; + const re = /update-context\b[^|\n]*\|\s*jq\b/; // allow-adhoc-markdown: shell-pipe-to-jq detector, not a markdown table scan for (const name of AUDITED) { const content = readWorkflow(name); if (content == null) continue; @@ -225,7 +225,7 @@ describe('#2589: config/model/verify lookups do not depend on jq', () => { test('resolve-execution lookups use --pick, not a jq pipe (sibling of resolve-model)', () => { // resolve-execution returns an object (model/profile/effort/effort_argv_string); // the native --pick descends it — same defect class as resolve-model. - const re = /resolve-execution\b[^|\n]*\|\s*jq\b/; + const re = /resolve-execution\b[^|\n]*\|\s*jq\b/; // allow-adhoc-markdown: shell-pipe-to-jq detector, not a markdown table scan for (const name of AUDITED) { const content = readWorkflow(name); if (content == null) continue; diff --git a/tests/worktree-cleanup.test.cjs b/tests/worktree-cleanup.test.cjs index a00e6541d..823ce1a2b 100644 --- a/tests/worktree-cleanup.test.cjs +++ b/tests/worktree-cleanup.test.cjs @@ -26,6 +26,7 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); const REPO_ROOT = path.join(__dirname, '..'); const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); @@ -1550,19 +1551,18 @@ function extractCwdGuardBash() { const afterDrift = afterStep.slice(driftIdx + driftMarker.length); - // Fence delimiter match. `content` is already LF-only from - // readFileNormalized() above, so `\r?\n` here is redundant, not load-bearing - // — kept anyway (harmless on already-normalized input) because a bare `\n` - // in a markdown-fence-shaped regex trips the local/no-crlf-fragile-split - // ESLint rule (it flags the pattern shape statically and cannot see that - // this call site's data already passed through the normalizing read). - const fenceRe = /```(?:bash|sh)\r?\n([\s\S]*?)```/; - const fenceMatch = fenceRe.exec(afterDrift); - if (!fenceMatch) { + // Fence delimiter match via the canonical seam. `content` is already LF-only + // from readFileNormalized() above, so `\r?\n` here is redundant, not + // load-bearing — kept anyway for robustness against un-normalized input. + const afterDriftLines = afterDrift.split(/\r?\n/); + const guardFence = scanFencedBlocks(afterDriftLines).find( + (b) => b.closeLineIdx !== -1 && ['bash', 'sh'].includes((b.infoString || '').trim()), + ); + if (!guardFence) { throw new Error(`extractCwdGuardBash: could not find \`\`\`bash fence after cwd-drift guard heading in ${EXECUTE_PHASE_PATH}`); } - const guardBash = fenceMatch[1]; + const guardBash = afterDriftLines.slice(guardFence.openLineIdx + 1, guardFence.closeLineIdx).join('\n'); if (!guardBash.trim()) { throw new Error('extractCwdGuardBash: extracted bash block is empty');