enhance(#3951): B6+B7 — widen two unreachable lint rules and make the guard ledger true (#3965)

* fix(#3951): two lint rules that could not reach the code they govern

B6 names two widenings. Measuring them first turned up a defect the criterion did
not know about, and refuted the reason it gave for one of them.

1. no-adhoc-markdown-parsing self-gates on its own filename.

   Lines 107-110 short-circuit create() to {} unless the path matches
   /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in
   eslint.config.mjs - but doing only that ships an INERT rule, because the gate
   still returns {} for every new path. Both halves have to change, and the gate
   is the load-bearing one.

   That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file
   to sit directly in src/. The registered glob is src/**/*.cts, which includes
   subdirectories. 28 .cts files - health-diagnostic-rules/ (10),
   installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2),
   vendor/ (2) - are inside the registered glob and silently skipped.

   Measured with the gate neutralized: 0 violations there today. The hole is
   hiding nothing right now, and is fixed anyway, because "no violations today" is
   not a property that keeps holding.

   The fix is not invented: require-subprocess-timeout.cjs:196 already carries the
   correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over.
   Checked the other 21 rules for the same bug - no-adhoc-regex-escape and
   no-private-binary-resolution short-circuit only to exempt their own seam file,
   which is the right shape, and no-crlf-fragile-split has no filename gate at
   all. This bug is unique to the one rule.

2. no-adhoc-regex-escape could not see the shape that actually occurs.

   Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'.
   Every check below it - the _SOURCE provenance check, the
   isSoleReturnOfOwnParameter shape - lives inside that branch, so
   new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all.
   Runtime data arrives as a property access far more often than as a bare
   identifier, which is exactly why this rule never fired on the #3477 ReDoS.

   Widened to MemberExpression, measured by AST walk across all five registered
   blocks rather than by grep. 27 sites, zero TSAsExpression:

     18  safe new RegExp(X.source, flags)  -> exempted, keyed strictly on the
         PROPERTY being `source`, never on the object. Keying on the object would
         wave through X.anything and buy nothing. B6 estimated ~10; that was an
         undercount.
      3  _SOURCE-suffixed constants reached through a required module namespace
         (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class
         the rule already recognizes for bare identifiers, extended to reach them.
         Without this the widening produces 3 false flags.
      6  real findings -> marked, each a test extracting a pattern from a shipped
         file at test time, where the runtime contract IS the product.

   Deliberately the NARROW MemberExpression form. The rule's own
   isSoleReturnOfOwnParameter doc comment records that an earlier broad
   "any non-literal identifier" heuristic produced ~25 false positives and was
   rejected; a re-run of the census after this change flags exactly the 6 above
   and nothing else.

Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts,
still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned
by a test proven to fail against the old regex. build:lib, lint and lint:ci all
exit 0.

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds

The rule self-gates on filename AND is registered on one glob, so widening either
half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and
scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the
same two.

A test pins that the gate and the registration AGREE, in both directions. The
original defect was a gate narrower than its registration; the failure mode of
this fix is a gate wider than its registration. Both are silent, so the test
asserts the pair rather than either half.

80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed
through the existing seams - scanFencedBlocks, collectSection, stripFencedCode,
tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable,
findTableWithColumns from markdown-table. Headerless STATE.md tables use
splitTableRow per line, because parseMarkdownTable needs a real delimiter row.

10 are suppressed, 12.5%, well under the third that would have meant the rule is
mis-scoped for tests/ rather than the tests carrying debt. Each names its reason:
three regression guards (#3873 / bug-#21) are deliberately independent of the
generator's own fence handling, and routing them through the seam would have them
test the generator against itself; one is a negative-text probe that extracts
nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the
table fingerprint and is not markdown parsing at all.

All ten sit in tests whose subject is .md content, which is normally a reason to
prefer the seam. The marker used is allow-adhoc-markdown, distinct from
no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports
the same 280/280 unverified count as before - checked rather than assumed, because
those two markers are easy to conflate.

The widening earned its keep immediately: it found a test that passed for the
wrong reason.

  tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the
  TYPE column instead of the DEFAULT column. notEqual('number', '600') is true
  forever, so the guard against workflow.subagent_timeout regressing to the old
  seconds default could never fire. docs/CONFIGURATION.md:434 is
  `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell
  index 2; the assertion is now row-scoped through splitTableRow and reads 300000.

That is the argument for the widening in one case: the violation was invisible to
lint, the suite was green, and the assertion was vacuous. A rule that cannot reach
a file cannot tell you the file is lying.

Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by
this 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('|') rather than the regex-literal fingerprints this rule
detects. They need new detectors. The epic assumed a wider glob would catch them.

build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and
scripts/** is 0 violations.

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(#3951): B7 — and #3356's defects were still live in the code

B7 asks that each closed child be driven fail-first with a behavioral identity
test at the CONSUMER's output. Four of eleven children had no test citing their
issue number. Auditing them by BEHAVIOR rather than by number-grep changed the
answer for three of the four.

#3364 and #2540 — traceability only. Both were implemented by #3941 and their
consumer-output tests exist and were shown failing-first; neither cited its
originating issue, so an audit that greps for the number reports them uncovered.
Tagged the specific asserting test in each file, following the citation form those
files already use.

#3372 — covered, but only at helper level, and the triage narrowed it. Of the four
commands the issue names, only estimate-cli's collectCalibrationSamples actually
enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from
ROADMAP/body text and never reach the sentinel path, so they are benign by
construction and were left alone rather than "fixed" into churn. The existing #3882
rows asserted the helper's return value. Added a consumer-output test driving
`query estimate-calibrate` and asserting sample_count and the persisted document.
RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real
CLI - sample_count 3, sentinel leaked; restored - sample_count 2.

#3356 — NOT covered, and BOTH halves of the defect were still live in source. The
issue is closed; the bug was not fixed. Fixed here rather than writing tests that
document a bug as correct.

  Defect 1, the contradicted row. quick.md:627 claimed
  `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did
  not: the `#` cell was a positional ordinal and `Directory` read `—`, because the
  route had no way to receive a quick id or task directory. Added OPTIONAL
  `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the
  original #2133 caller - omits them and gets the byte-identical prior row, so
  nothing existing changes. A caller that HAS a real id and directory now gets the
  canonical row quick.md:632 renders. The false-equivalence sentence itself is
  corrected rather than left to mislead the next reader.

  Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no
  options, so a body-only append to the Quick Tasks table triggered a full
  re-derive of the disk-derived progress.* frontmatter. Every other body-only
  writer passes { resync: false } - src/state.cts's own docstring prescribes it -
  and this route was the lone outlier. RED proof: reverted the option, seeded a
  project with 2 real phase dirs and a curated total_phases of 25, ran
  quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25.

That second one is the shape this epic exists to close: a silent write that
replaces curated state with a re-derivation nobody asked for, exit 0 throughout.

build:lib, lint and lint:ci all exit 0.

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(#3951): amend B6's ledger to what was measured, and document the new flags

The ADR gains a ledger amendment in its own correction style - the sixth wrong
premise it records, found the same way as the other five, by measuring before
building.

B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the
epic's filing commit to origin/next. The attribution is the point, though. Five of
the seven came from PRs unrelated to this epic, one was added by a phase of it, and
the epic did retire something sub-file - #3884 removed a detector with an explicit
"net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two
already carry retractions in this same document, and a sweep of all 22 rules plus
every scripts/lint-* found no provably dead guard. There is no honest way to make
the count fall; forcing it would trade coverage for a number, which is the Goodhart
outcome Decision 6 exists to prevent.

The amendment also records that B6's own prescribed fix for one widening was inert.
no-adhoc-markdown-parsing self-gates on its filename, so widening only the files:
glob - which is what the criterion says to do - ships a rule that still returns {}
for every new path. And #3426/#3239 are not reachable by that widening at all;
their scans use line filters and split('|'), not the regex fingerprints the rule
detects. The roster row tracked them against the wrong mechanism.

Three roster rows updated from aspiration to fact: the two widenings are DONE with
their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather
than "expected casualty - verify before retiring", because Phase 5 verified it and
kept it.

The rule Decision 6 should carry forward is stated plainly: a guard ledger is a
claim about COVERAGE, not 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.

CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says
plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the
append no longer re-derives progress frontmatter.

New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited.
Changeset is Changed, pr:0 pending backfill.

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3951): correct four rows that pinned the lint rule's old narrow reach

The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs.
They are stale tests, not a regression: four rows assert that
no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the
contract this deliverable changes.

Confirmed by reading rather than inferred from the names - the row at :1981 used
filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots
the rule now covers on purpose.

Worth recording WHY local gates missed this. npm run lint and lint:ci were green,
and the touched test files passed standalone. Lint only reports violations in real
files; these rows assert the rule's REACH using synthetic RuleTester filenames, so
nothing but the full suite could see them. Local green on a rule change says
nothing about the rule's own tests.

Each row is rewritten with BOTH halves rather than flipped from valid to invalid:

  - the same fingerprint under tests/ or scripts/ is now flagged, with the right
    messageId
  - the negative space is preserved - the same fingerprint under a path outside
    all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged

The second half is the one that matters. Without it the rule has no boundary and
nothing would catch an over-wide gate later, which is the mirror image of the bug
this deliverable just fixed.

Each row is renamed to state the current contract; the old names said
"non-src/*.cts ... is not flagged" and would have been actively misleading once
the bodies changed.

Proven to test the widening rather than restate it: every flagged half was run
against HEAD~2's pre-widening rule and does NOT fire there, then against the
current rule and does. 12/12 on that probe; the full file is 178/178.

Swept for the same staleness elsewhere and found none.
require-subprocess-timeout's own "inert outside src/*.cts" row is untouched -
that rule's gate was not widened here - and no-adhoc-regex-escape's test file
already carries correctly-targeted rows.

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3951): acknowledge the quick.md growth the attribution guard reported

The full suite came back RED with one failure, and it is mine:

  1 file(s) grew without an acknowledgment:
    quick.md grew 364 bytes

gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting
its false 'performs the equivalent write' claim trips emitted-attribution by
construction. This is the acknowledgment, not a workaround - there is nothing
to regenerate.

The fragment names ONE path, which is the only one the guard reported. The four
spent acknowledgments it also listed (audit-uat, plan-phase, progress, review)
belong to other fragments whose ripple the base already absorbs; they are inert,
not failures, and are deliberately NOT copied here - naming paths I did not
change would make this record false in the other direction.

Byte figure corrected before committing: the guard reported 37220 -> 37584
(+364), but origin/next has since moved and quick.md is 37232 there now, so the
measured delta is +352. The reason text says so and names the base as a moving
figure rather than pinning a number that is already stale.

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment

The acknowledgment mechanism changed under this branch. Merging next brought in
the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which
was in the merge status and which I did not register at the time - and the guard
now says so directly:

  Add a trailer to a commit in this PR (never a new file).
    Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate>

So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on
arrival. A fragment file is no longer read by anything, and leaving it would be a
dead record that looks like an active one. It is deleted here rather than kept
"just in case".

The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier
fragment said +352, measured before the merge auto-merged quick.md itself. The
trailer carries no number, which is the better design - the figure was stale
twice in two attempts.

Refs #3951

Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3951): backfill changeset pr number

Refs #3951

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-27 23:10:49 -04:00
committed by GitHub
parent 2ea5efc151
commit 15af0f5536
63 changed files with 1305 additions and 257 deletions

View File

@@ -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)

View File

@@ -989,6 +989,15 @@ node gsd-tools.cjs verify-path-exists <path>
# Append a row to STATE.md's "Quick Tasks Completed" table (schema-backed; #2133)
node gsd-tools.cjs quick-tasks-append --task "<description>"
# 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 "<description>" --quick-id <id> --slug <slug>
node gsd-tools.cjs quick-tasks-append --task "<description>" --directory "[<id>-<slug>](./quick/<id>-<slug>/)"
# 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/<version>-quick/ and clears this table, without a full `milestone complete`.

View File

@@ -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`._
<!-- FEATURES:END -->

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 {};
}

View File

@@ -300,6 +300,21 @@ function isReviewedPatternFragmentIdentifier(identifierName, scope) {
return false;
}
/**
* Unwrap a chain of TypeScript `<expr> 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 `<expr> 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(<MemberExpression>)` 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' });
}
}
},
};

View File

@@ -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',

View File

@@ -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 <description> (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);
}

View File

@@ -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 <text>` 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 <text>` 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 <id> --slug <slug>` (or `--directory <link>` directly) to get the byte-identical row this step renders above (#3356).
**7d. Update "Last activity" line:**

View File

@@ -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);

View File

@@ -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');

View File

@@ -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) => {

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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 </codex_skill_adapter> 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('</codex_skill_adapter>', 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 </codex_skill_adapter> 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('</codex_skill_adapter>', 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();
}
/**

View File

@@ -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'
);
});

View File

@@ -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_]+):/);

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -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}"`

View File

@@ -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(<identifier already computed upstream>) 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)', () => {

View File

@@ -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: [],

View File

@@ -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,

View File

@@ -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"');

View File

@@ -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`

View File

@@ -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];

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -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);
}

View File

@@ -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}`);
}
}
}

View File

@@ -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

View File

@@ -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, <runtime>) 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 <runtime>) — 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');

View File

@@ -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', () => {

View File

@@ -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');

View File

@@ -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']) {

View File

@@ -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;

View File

@@ -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 </step> 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) {

View File

@@ -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');

View File

@@ -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)', () => {

View File

@@ -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;

View File

@@ -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'

View File

@@ -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}"`);
}

View File

@@ -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}`);

View File

@@ -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:<dir>/<file> 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;
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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;
}

View File

@@ -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)})`);

View File

@@ -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),

View File

@@ -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

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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];
}

View File

@@ -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];

View File

@@ -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(/<verify>[\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(/<verify>[\s\S]*?<\/verify>/g) || [];
assert.strictEqual(verifies.length, 1, `the tracer template must contain exactly ONE <verify>, found ${verifies.length}`);
const inner = verifies[0].replace(/^<verify>/, '').replace(/<\/verify>$/, '').replace(/\s+/g, ' ').trim();
assert.match(inner, /^<automated>[^<>]+<\/automated>$/,

View File

@@ -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
);
});
});

View File

@@ -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()];

View File

@@ -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');

View File

@@ -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'),
};
}

View File

@@ -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),

View File

@@ -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 <key> ... | 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 <field> 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 <field> 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;

View File

@@ -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');