Merge remote-tracking branch 'origin/next' into chore/backmerge-main-to-next-350fba48
# Conflicts: # .changeset/924-claude-flat-skill-layout.md # .changeset/happy-finches-travel.md
This commit is contained in:
5
.changeset/witty-finches-hum.md
Normal file
5
.changeset/witty-finches-hum.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 1386
|
||||
---
|
||||
**Decision-coverage gate now reads markdown-header and em-dash decisions, and fails loud when it can't parse them** — `check.decision-coverage-plan` (a blocking gate) and `gap-analysis` previously extracted **zero** decisions from a populated CONTEXT.md that recorded its decisions under markdown headers (`## Locked decisions`) or with em-dash bullets (`- **D-1 — title**`), and silently reported a clean pass — so real decisions went un-checked. Decisions in those shapes are now recognized, and when decision-shaped content cannot be parsed (or a `- **D-NN**` bullet is malformed), the gate fails loud with a format-mismatch message instead of passing. (#1386)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -67,6 +67,7 @@ build/
|
||||
# by `npm run build:lib`). Source of truth is src/; these are emitted, never edited.
|
||||
# Published via prepublishOnly; built before test via pretest. Grows as modules migrate.
|
||||
/tsconfig.build.tsbuildinfo
|
||||
/gsd-core/bin/lib/markdown-sectionizer.cjs
|
||||
/gsd-core/bin/lib/research-store.cjs
|
||||
/gsd-core/bin/lib/research-provider.cjs
|
||||
/gsd-core/bin/lib/package-legitimacy.cjs
|
||||
|
||||
@@ -118,6 +118,9 @@ Primary installer for all runtimes. Single production file: `bin/install.js` (ge
|
||||
### I/O Module
|
||||
Module owning the tool's CLI I/O primitives: `output()` result emission (with large-payload temp-file spillover via `GSD_TEMP_DIR`/`ensureGsdTempDir`/`reapStaleTempFiles`), `error()` stderr emission with exit-code mapping, and the JSON-error-mode toggle (`setJsonErrorMode`/`getJsonErrorMode`, `ERROR_REASON`). Extracted from the Core module per ADR-857 rollout phase 1 (#859) so feature modules (`graphify`, `intel`, `audit`, `profile-pipeline`) depend on a small I/O seam instead of the core god-module; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/io.cjs` (generated from `src/io.cts`).
|
||||
|
||||
### Markdown Sectionizer
|
||||
Canonical markdown-structure parsing seam (`gsd-core/bin/lib/markdown-sectionizer.cjs`, generated from `src/markdown-sectionizer.cts`). Pure functions, Node built-ins only. Exports: `stripFencedCode(content) → { text, unterminatedFence }` (CommonMark-correct state machine, CRLF-safe, signals unterminated fences); `tokenizeHeadings(content) → HeadingToken[]` (ATX headings outside fenced blocks, `{ level, text, line, offset }`); `collectSections(content, stopPredicate) → Section[]` (line-by-line section collection driven by a heading predicate); `collectSection(content, headingPredicate, { levelBounded, stripFences }) → Section | null` (single named section with level-bounded stop); `iterateBullets(sectionText) → BulletItem[]` (dash/checkbox/numbered markers with indented continuation); `extractTaggedBlocks(content, tagName) → string[]` (inner text of every `<tagName>…</tagName>` block in document order, tagName regex-escaped, caller decides fence-stripping — generalises `decisions.cts`'s bespoke extractor for T1); `replaceSection(content, section, newBody) → string` (pure character-offset splice using `Section.bodyStart`/`bodyEnd` for read-modify-write callers — eliminates T6 `state.cts`'s 7× inline `content.replace` pattern). `Section` carries `bodyStart`/`bodyEnd` offsets for `replaceSection`. ADR-1372 (epic #1372) establishes this seam and a tiered migration plan (T0–T7) to retire the 8+ ad-hoc markdown parsers and ~20 inline section-collects across `src/*.cts`. New `src/*.cts` modules must import this seam instead of hand-rolling fence strippers or heading-regex section walks (enforced by the `no-adhoc-markdown-parsing` ESLint rule landing in tier T7).
|
||||
|
||||
### Roadmap Parser Module
|
||||
Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone extraction, milestone/phase lookups, and milestone-phase filtering (`stripShippedMilestones`, `extractCurrentMilestone`, `replaceInCurrentMilestone`, `getRoadmapPhaseInternal`, `getMilestoneInfo`, `getMilestonePhaseFilter`). Depends only on leaf modules (`phase-id`, `planning-workspace`, `shell-command-projection`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2b (#870), resolving the ROADMAP.md parse/write straddle so the Roadmap module (`roadmap.cjs`, which owns ROADMAP.md mutation) imports parsing directly instead of through Core; the `core.cjs` re-export spine was retired in epic #1267, so callers import this leaf directly. Source of truth: `gsd-core/bin/lib/roadmap-parser.cjs` (generated from `src/roadmap-parser.cts`).
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@
|
||||
"legacy-cleanup.cjs",
|
||||
"loop-host-contract.cjs",
|
||||
"loop-resolver.cjs",
|
||||
"markdown-sectionizer.cjs",
|
||||
"milestone.cjs",
|
||||
"model-catalog.cjs",
|
||||
"model-profiles.cjs",
|
||||
|
||||
@@ -437,6 +437,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) |
|
||||
| `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` |
|
||||
| `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks <point> [--config-dir <path>]` |
|
||||
| `markdown-sectionizer.cjs` | Canonical markdown-structure parsing seam (ADR-1372, epic #1372) — pure, Node built-ins only; exports `stripFencedCode` (CommonMark-correct fence stripper, CRLF-safe), `tokenizeHeadings` (ATX headings outside fenced blocks), `collectSections`/`collectSection` (line-by-line section collection with `bodyStart`/`bodyEnd` offsets), `iterateBullets` (dash/checkbox/numbered markers), `extractTaggedBlocks` (inner text of `<tag>…</tag>` blocks, caller decides fence-stripping), and `replaceSection` (pure character-offset body splice for read-modify-write callers); foundation for T0–T7 migration tiers retiring 8+ ad-hoc parsers |
|
||||
| `milestone.cjs` | Milestone archival, requirements marking |
|
||||
| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers |
|
||||
| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table |
|
||||
|
||||
92
docs/adr/1372-markdown-sectionizer-seam.md
Normal file
92
docs/adr/1372-markdown-sectionizer-seam.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# ADR-1372: Canonical markdown-structure parsing — the `markdown-sectionizer` seam
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-17
|
||||
- **Issue:** [#1372](https://github.com/open-gsd/gsd-core/issues/1372) (epic)
|
||||
- **Resolves (via tier T1):** [#1364](https://github.com/open-gsd/gsd-core/issues/1364), [#1365](https://github.com/open-gsd/gsd-core/issues/1365)
|
||||
- **Relates:** [#1343](https://github.com/open-gsd/gsd-core/issues/1343), [#1324](https://github.com/open-gsd/gsd-core/issues/1324), [#447](https://github.com/open-gsd/gsd-core/issues/447) — prior single-parser markdown bugs
|
||||
- **Pattern precedent:** [ADR-857](857-capability-system.md) / epic [#1267](https://github.com/open-gsd/gsd-core/issues/1267) (retire a duplicated spine via tiered children)
|
||||
|
||||
## Context
|
||||
|
||||
GSD parses a lot of structured markdown — `CONTEXT.md`, `ROADMAP.md`, `STATE.md`, `*-PLAN.md`, UAT files, ADRs, frontmatter. There is **no shared primitive** for the three operations every one of these parsers needs (strip fenced code, tokenize headings into sections, iterate bullets), so each module hand-rolls them. A grounded map of `src/*.cts` found:
|
||||
|
||||
- **8+ independent markdown parsers**: `decisions`, `gap-checker`, `roadmap-parser`, `state`, `uat`, `uat-predicate`, `adr-parser`, `check-command-router`.
|
||||
- **3–4 independent fenced-code strippers of different fidelity**: `decisions.cts` (fragile regex, no unclosed-fence handling), `roadmap-parser.cts` `stripFencedLines` (state machine, **duplicated 3× in one file**), `uat-predicate.cts` `_stripFencedBlocks` (CommonMark-correct, CRLF-safe, signals an unterminated fence), `check-command-router.cts` `stripCommentsAndFences` (another regex copy).
|
||||
- **~20 hand-rolled section-collects**, with `state.cts` alone re-implementing the same `/(###?\s*<Name>\s*\n)([\s\S]*?)(?=\n###?|$)/i` shape **13 times**.
|
||||
|
||||
The consequence is a recurring maintenance game: every "the parser missed structure X" report (#1343 bullet-before-colon, #1364 markdown-header + em-dash, #1324 glued phase tokens, #447 gap scoping) is fixed *locally* with another regex, and the same class of bug re-opens in the next parser. The fixes do not compound — they accrete. Worse, in the decision-coverage case the failure mode is **silent**: a blocking gate that cannot parse its input reports `passed:true, covered 0/0` and ships the phase with its decisions unchecked.
|
||||
|
||||
There are two root causes, and a durable fix must address both:
|
||||
|
||||
1. **No canonical structure primitive** — so structural correctness (fences, CRLF, heading levels, Unicode, bullet shapes) is re-litigated per module and tested unevenly.
|
||||
2. **Nothing prevents the next ad-hoc parser** — a new PR can add a fourth fence stripper and no gate objects, so the divergence regrows even after a cleanup.
|
||||
|
||||
## Decision
|
||||
|
||||
Establish a single canonical markdown-structure seam and make ad-hoc markdown scanning a lint-enforced prohibition. Migrate every existing parser onto the seam incrementally, tracked as tiered children of epic #1372.
|
||||
|
||||
### 1. The seam — `src/markdown-sectionizer.cts` (pure, Node built-ins only)
|
||||
|
||||
No external markdown library (the "no external dependencies in core" rule stands). Pure functions, string-in → value-out, no I/O:
|
||||
|
||||
- `stripFencedCode(content) → { text, unterminatedFence }` — the CommonMark-correct state machine promoted from `uat-predicate.cts` `_stripFencedBlocks` (CRLF-safe; ≤3-space indent tolerated; closes only on a same-or-longer fence run). `unterminatedFence` is a reusable malformed-input diagnostic.
|
||||
- `tokenizeHeadings(content) → HeadingToken[]` — ATX headings `{ level, text, line, offset }` in document order.
|
||||
- `collectSections(content, stopPredicate)` and `collectSection(content, headingPredicate, { levelBounded, stripFences })` — line-by-line (not greedy-regex) section collection; `levelBounded` encodes the dominant "stop at same-or-higher-level heading" pattern. Both populate `bodyStart`/`bodyEnd` character offsets on the returned `Section` for use by `replaceSection`.
|
||||
- `iterateBullets(sectionText) → BulletItem[]` — dash/asterisk/plus, checkbox (`- [ ]`/`- [x]`), and numbered markers, with indented continuation-line accumulation.
|
||||
- `extractTaggedBlocks(content, tagName) → string[]` — returns the inner text of every `<tagName>…</tagName>` block in document order; `tagName` is regex-escaped; the caller decides ordering (does not strip fences). Generalises `decisions.cts`'s bespoke `<decisions>` extractor for T1 adoption.
|
||||
- `replaceSection(content, section, newBody) → string` — pure character-offset splice using `section.bodyStart`/`bodyEnd`; replaces a section body in a read-modify-write workflow (e.g. `state.cts`'s 7× inline `content.replace(/(##\s*Name\s*\n)([\s\S]*?)(?=\n##|$)/, ...)` pattern). CRLF-safe.
|
||||
|
||||
The seam is fully tested against the parser QA matrix (CRLF, Unicode headings, headings-inside-fences, unterminated fences, nested levels, malformed bullets) **once**, so every adopter inherits that correctness instead of re-deriving it.
|
||||
|
||||
### 2. Prohibition + enforcement — `local/no-adhoc-markdown-parsing`
|
||||
|
||||
A new ESLint rule in `eslint-rules/no-adhoc-markdown-parsing.cjs` (wired in `eslint.config.mjs`, mirroring `local/no-source-grep`) flags new hand-rolled markdown-structure scanning outside the seam — fenced-code strip regexes, `split(/\r?\n/)` + heading-regex section walks, and `D-`/checkbox bullet regexes — in `src/*.cts`. Existing sites are **grandfathered** by an explicit allowlist that is burned down as each tier migrates (the same grandfathering pattern `no-source-grep` uses). New code must import the seam. This is the part that stops the game permanently: after this rule lands, a PR cannot introduce a fourth fence stripper without a reviewer-visible failure.
|
||||
|
||||
### 3. Decisions realization (tier T1) — typed result + fail-loud gate
|
||||
|
||||
The first behavioral adopter, which also resolves the two open bugs. `decisions.cts` is rewritten onto the seam, and a typed result distinguishes the states the blocking gate cares about:
|
||||
|
||||
```
|
||||
type DecisionExtraction = {
|
||||
decisions: Decision[];
|
||||
outcome: 'parsed' | 'none-present' | 'could-not-parse';
|
||||
};
|
||||
```
|
||||
|
||||
`parseDecisions(content): Decision[]` is preserved as a thin delegate (consumers untouched); `extractDecisions(content): DecisionExtraction` is the typed entry point. `cmdDecisionCoveragePlan` (blocking) treats `could-not-parse` — content is decision-shaped (a `<decisions>` block, a `/decisions?/i` heading, `\bD-` tokens, or `unterminatedFence`) yet 0 decisions extracted — as a **WARN/fail** ("could not parse decisions — possible format mismatch") instead of a green pass (**resolves #1365**). Routing through the seam recognises the markdown-header + em-dash variants (**resolves #1364**). Recall-first by design: a false "could-not-parse" is a loud warning a human clears; a false "none-present" is the silent bypass we are deleting.
|
||||
|
||||
### 4. Migration tiers (epic #1372 children)
|
||||
|
||||
Each tier is its own issue + PR (issue-first; one concern per PR), behaviour-preserving except T1, each separately tested, each burning down the `no-adhoc-markdown-parsing` grandfather list for the files it touches.
|
||||
|
||||
| Tier | Scope | Risk | Notes |
|
||||
|---|---|---|---|
|
||||
| **T0** | Seam foundation: `markdown-sectionizer.cts` + QA-matrix tests | none | No migration, no behavior change. Foundational. |
|
||||
| **T1** | `decisions.cts` + coverage gate: adopt seam, typed result, fail-loud | low–med | **Resolves #1364, #1365.** First behavioral adopter. |
|
||||
| **T2** | `adr-parser.cts`: `parseSections`/`splitEntries` → seam | none | CLI-only, no in-process callers — the safe prototype; its `parseSections` is the API shape the seam generalizes. |
|
||||
| **T3** | `check-command-router.cts` + `gap-checker.cts`: dedupe `stripCommentsAndFences`, designated-section walk, requirements bullets | low | Gate-adjacent; covered by existing gate tests. |
|
||||
| **T4** | `roadmap-parser.cts`: collapse the 3× inline fence loop + `computeSectionEnd` | med | Heavily tested; watch milestone-section boundaries. |
|
||||
| **T5** | `uat.cts` + `uat-predicate.cts`: donate the canonical stripper, migrate heading/section scans | med | `_stripFencedBlocks` becomes the seam's source in T0; T5 removes the local copy. |
|
||||
| **T6** | `state.cts`: 13 inline section-collects → `collectSection` | high | Highest payoff, highest risk — load-bearing for STATE.md mutation. Surgical, full regression, last. |
|
||||
| **T7** | Enforcement: `no-adhoc-markdown-parsing` ESLint rule + grandfather burn-down | low | Lands once enough tiers are migrated that the grandfather list is small; thereafter new ad-hoc parsing is blocked. |
|
||||
|
||||
`frontmatter.cts` stays as-is — YAML frontmatter is a different grammar with its own well-used shared parser (`extractFrontmatter`); it is out of scope.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
No user-facing or authoring change. Behaviour-preserving migrations (T2–T6) keep each parser's outputs byte-identical (verified by each parser's existing tests + added characterization tests). T1 is the only behavior change: additive decision recall + the could-not-parse WARN; the `<decisions>` block stays canonical and parses identically (block presence still takes precedence). Internal API churn is contained per-tier; public CLI contracts are unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** structural correctness (fences/CRLF/levels/bullets) is solved and tested once; the silent fail-open class is eliminated for the blocking gate; the per-module regex pile stops growing *and* is prohibited from regrowing; future markdown parsers inherit correctness for free; the change models the repo's own typed-IR / no-source-grep philosophy. Retires 3–4 duplicate strippers and ~20 inline section-collects.
|
||||
|
||||
**Negative / risks:** a large surface migrated incrementally — mitigated by tiering (zero-risk T2 prototype first, high-risk `state.cts` last, behavior-preserving with characterization tests, the epic visible end-to-end). A new shared module is a dependency for adopters — mitigated by purity + exhaustive tests. The recall-first "could-not-parse" heuristic may occasionally warn on decision-shaped-but-empty content — acceptable and tunable; a loud false alarm beats the silent miss it replaces. The enforcement rule (T7) must grandfather precisely to avoid blocking unrelated PRs mid-migration.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Point-fix each parser bug as it's reported (status quo).** Rejected — this is the game we are ending; fixes accrete instead of compounding and the same class recurs in the next parser. The maintainer's explicit directive is a solution-wide structural fix, not another file edit.
|
||||
- **Consolidate the primitive but skip the enforcement rule.** Rejected — without the lint guard the divergence regrows; the next PR adds a fifth stripper and no gate objects. The prohibition is what makes the consolidation durable.
|
||||
- **External markdown library (remark/markdown-it/unified).** Rejected — "no external dependencies in core" is a hard rule.
|
||||
- **LLM / semantic extraction.** Rejected — `gsd-tools` is a deterministic, no-LLM, zero-dependency CLI with regression-tested pure `Result` functions; an LLM breaks the determinism/testability a CI gate requires and contradicts the repo's no-LLM precedent.
|
||||
- **One big-bang PR migrating every parser.** Rejected — `state.cts` alone is load-bearing and high-risk; a single PR would be unreviewable and unmergeable. Gall's Law: the working complex system is grown from a working simple seam (T0) plus incremental, individually-verified migrations.
|
||||
@@ -160,6 +160,8 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/capability-writer.cjs',
|
||||
// issue #1355: tsc-generated runtime artifact — lint the src/teams-status.cts source.
|
||||
'gsd-core/bin/lib/teams-status.cjs',
|
||||
// ADR-1372: tsc-generated runtime artifact — lint the src/markdown-sectionizer.cts source.
|
||||
'gsd-core/bin/lib/markdown-sectionizer.cjs',
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ const { planningDir } = planningWorkspaceMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { findPhaseInternal } = phaseLocatorMod;
|
||||
import { parseDecisions } from './decisions.cjs';
|
||||
import { extractDecisions } from './decisions.cjs';
|
||||
import type { Decision } from './decisions.cjs';
|
||||
import { checkUiPresence } from './ui-safety-gate.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -223,8 +223,12 @@ function buildVerifyMessage(notHonored: UncoveredItem[]): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function loadTrackableDecisions(contextPath: string): Decision[] {
|
||||
return parseDecisions(readIfExists(contextPath)).filter((decision) => decision.trackable);
|
||||
function loadDecisionExtraction(contextPath: string): { trackable: Decision[]; outcome: 'parsed' | 'none-present' | 'could-not-parse' } {
|
||||
const extraction = extractDecisions(readIfExists(contextPath));
|
||||
return {
|
||||
trackable: extraction.decisions.filter((d) => d.trackable),
|
||||
outcome: extraction.outcome,
|
||||
};
|
||||
}
|
||||
|
||||
function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolean): void {
|
||||
@@ -240,7 +244,33 @@ function cmdDecisionCoveragePlan(projectDir: string, args: string[], raw: boolea
|
||||
return;
|
||||
}
|
||||
|
||||
const decisions = loadTrackableDecisions(contextPath);
|
||||
const { trackable: decisions, outcome } = loadDecisionExtraction(contextPath);
|
||||
|
||||
// #1365 fail-loud gate: any could-not-parse outcome must NOT silently pass —
|
||||
// even when some decisions were extracted (e.g. D-01 valid but D-02 malformed).
|
||||
// A parse-miss on ANY bullet means the gate cannot certify full coverage.
|
||||
// Fire independent of decisions.length so a partial-parse still blocks.
|
||||
if (outcome === 'could-not-parse') {
|
||||
const partialParse = decisions.length > 0;
|
||||
output({
|
||||
passed: false,
|
||||
skipped: false,
|
||||
reason: 'could-not-parse',
|
||||
total: decisions.length,
|
||||
covered: 0,
|
||||
uncovered: [],
|
||||
message: partialParse
|
||||
? 'Decision coverage gate: decisions could not be fully parsed — one or more ' +
|
||||
'`- **D-NN ...**` bullets appear malformed (missing `:` or ` — ` separator). ' +
|
||||
'Fix the bullet format so all D-NN decisions can be read before re-running the gate.'
|
||||
: 'Decision coverage gate: could not parse decisions — possible format mismatch. ' +
|
||||
'The CONTEXT.md appears to be decision-shaped (has a <decisions> block, a decisions heading, ' +
|
||||
'or D- tokens) but no D-NN bullets could be extracted. Check the formatting of the decisions ' +
|
||||
'block and ensure bullets follow the `- **D-NN:** text` or `- **D-NN — title** body` form.',
|
||||
}, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decisions.length === 0) {
|
||||
output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
|
||||
return;
|
||||
@@ -318,7 +348,29 @@ function cmdDecisionCoverageVerify(projectDir: string, args: string[], raw: bool
|
||||
return;
|
||||
}
|
||||
|
||||
const decisions = loadTrackableDecisions(contextPath);
|
||||
const { trackable: decisions, outcome: decisionOutcome } = loadDecisionExtraction(contextPath);
|
||||
|
||||
// Mirror could-not-parse surface for verify (non-blocking advisory WARN).
|
||||
// Fire independent of decisions.length — a parse-miss on any bullet must surface,
|
||||
// even when some decisions were partially extracted (#1365 fix-parity with plan gate).
|
||||
if (decisionOutcome === 'could-not-parse') {
|
||||
const partialParse = decisions.length > 0;
|
||||
output({
|
||||
skipped: false,
|
||||
blocking: false,
|
||||
reason: 'could-not-parse',
|
||||
total: decisions.length,
|
||||
honored: 0,
|
||||
not_honored: [],
|
||||
message: partialParse
|
||||
? 'Decision coverage verify (warning): decisions could not be fully parsed — one or more ' +
|
||||
'`- **D-NN ...**` bullets appear malformed. Fix the bullet format in the CONTEXT.md decisions block.'
|
||||
: 'Decision coverage verify (warning): could not parse decisions — possible format mismatch. ' +
|
||||
'Check the formatting of the CONTEXT.md decisions block.',
|
||||
}, raw, undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (decisions.length === 0) {
|
||||
output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw, undefined);
|
||||
return;
|
||||
|
||||
@@ -7,8 +7,22 @@
|
||||
* Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
|
||||
* Returns {id, text, category, tags, trackable} per decision.
|
||||
* CJS callers that only use {id, text} safely ignore the extra fields.
|
||||
*
|
||||
* ADR-1372 T1: rewritten to adopt the markdown-sectionizer seam.
|
||||
* - `stripFencedCode` → seam's `stripFencedCode` (CommonMark-correct)
|
||||
* - `extractDecisionsBlock` → seam's `extractTaggedBlocks(content,'decisions')`
|
||||
* - Markdown-header fallback → seam's `collectSection(content, /decisions?/i, ...)`
|
||||
* - Outer bullet loop → seam's `iterateBullets` (for the header-fallback path)
|
||||
*
|
||||
* Resolves #1364 (markdown-header + em-dash recall) and #1365 (fail-loud gate).
|
||||
*/
|
||||
|
||||
import {
|
||||
stripFencedCode,
|
||||
extractTaggedBlocks,
|
||||
collectSection,
|
||||
} from './markdown-sectionizer.cjs';
|
||||
|
||||
export interface Decision {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -17,6 +31,21 @@ export interface Decision {
|
||||
trackable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed extraction result distinguishing three states the blocking gate cares about:
|
||||
* - 'parsed' — ≥1 decision was successfully extracted
|
||||
* - 'none-present' — content has no decision signals; nothing to check
|
||||
* - 'could-not-parse'— content is decision-shaped (has a <decisions> block, a
|
||||
* /decisions?/i heading, a \bD- token, or an unterminated fence)
|
||||
* yet 0 decisions were extracted → format mismatch, fail-loud
|
||||
*/
|
||||
export type DecisionOutcome = 'parsed' | 'none-present' | 'could-not-parse';
|
||||
|
||||
export interface DecisionExtraction {
|
||||
decisions: Decision[];
|
||||
outcome: DecisionOutcome;
|
||||
}
|
||||
|
||||
const DISCRETION_HEADINGS = new Set([
|
||||
"claude's discretion",
|
||||
'claudes discretion',
|
||||
@@ -24,60 +53,46 @@ const DISCRETION_HEADINGS = new Set([
|
||||
]);
|
||||
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
|
||||
|
||||
// ─── Bullet parsers (decisions-specific grammar) ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip fenced code blocks from `content` so example `<decisions>` snippets
|
||||
* inside ```` ``` ```` do not pollute the parser (review F11).
|
||||
* Colon form: `- **D-NN[ [tags]]:** text`
|
||||
* (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`)
|
||||
*/
|
||||
function stripFencedCode(content: string): string {
|
||||
return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
|
||||
const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
|
||||
|
||||
/**
|
||||
* Em-dash form: `- **D-NN[ [tags]] — title** body`
|
||||
* The em-dash (U+2014) or its lookalike separates the ID+tags group from a title
|
||||
* that lives inside the bold markers; the body (which may be empty) follows
|
||||
* outside the closing `**`. This form was not handled pre-T1 (bug #1364).
|
||||
*
|
||||
* Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
|
||||
*/
|
||||
const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
|
||||
|
||||
interface ParseDecisionLinesResult {
|
||||
decisions: Decision[];
|
||||
parseMisses: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the inner text of EVERY `<decisions>...</decisions>` block in
|
||||
* order, concatenated by `\n\n`. Returns null when no block is present.
|
||||
* Parse decision lines from a block of text (the inner text of a <decisions>
|
||||
* or markdown-header section body). Returns the extracted decisions and a count
|
||||
* of parse-misses (lines that looked like D-NN bullets but failed both regexes).
|
||||
*
|
||||
* CONTEXT.md may legitimately contain more than one block (for example, a
|
||||
* "current decisions" block plus a "carry-over from prior phase" block);
|
||||
* dropping all-but-the-first silently lost the second batch (review F13).
|
||||
* FIX B (#1365): parseMisses > 0 means the caller must treat the result as
|
||||
* could-not-parse even when some decisions were extracted — a silent drop is
|
||||
* worse than a fail-loud signal.
|
||||
*/
|
||||
function extractDecisionsBlock(content: string): string | null {
|
||||
const cleaned = stripFencedCode(content);
|
||||
const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
|
||||
if (matches.length === 0)
|
||||
return null;
|
||||
return matches.map((m) => m[1]).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse trackable decisions from CONTEXT.md content.
|
||||
*
|
||||
* Returns ALL D-NN decisions found inside `<decisions>` (including
|
||||
* non-trackable ones, with `trackable: false`). Callers that only want the
|
||||
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
|
||||
*/
|
||||
export function parseDecisions(content: unknown): Decision[] {
|
||||
if (!content || typeof content !== 'string')
|
||||
return [];
|
||||
const block = extractDecisionsBlock(content);
|
||||
if (block === null)
|
||||
return [];
|
||||
function parseDecisionLines(block: string): ParseDecisionLinesResult {
|
||||
const lines = block.split(/\r?\n/);
|
||||
const out: Decision[] = [];
|
||||
let category = '';
|
||||
let inDiscretion = false;
|
||||
// Bullet line: `- **D-NN[ [tags]]:** text`
|
||||
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
||||
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
||||
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
||||
// CJS callers consume {id, text} and ignore the optional extras.
|
||||
// #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
|
||||
// such as `(parenthetical)`, an em-dash, or other prose between the optional
|
||||
// bracket-tag group and the closing `:**` is tolerated rather than silently
|
||||
// dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
|
||||
// correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
|
||||
// unchanged.
|
||||
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
|
||||
let current: Decision | null = null;
|
||||
let parseMisses = 0;
|
||||
|
||||
const flush = (): void => {
|
||||
if (current) {
|
||||
current.text = current.text.trim();
|
||||
@@ -85,61 +100,182 @@ export function parseDecisions(content: unknown): Decision[] {
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Track category headings (`### Heading`)
|
||||
const headingMatch = trimmed.match(/^###\s+(.+?)\s*$/);
|
||||
if (headingMatch) {
|
||||
flush();
|
||||
category = headingMatch[1];
|
||||
// Strip the full unicode-quote family so any rendering of "Claude's
|
||||
// Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
|
||||
// double-quote variants U+201C/D/E/F, etc.) collapses to the same key
|
||||
// (review F20).
|
||||
// Discretion" (ASCII apostrophe, curly U+2019 ’, U+2018 ‘,
|
||||
// U+201A, U+201B, double-quote variants U+201C/D/E/F, etc.) collapses
|
||||
// to the same key (FIX C + review F20).
|
||||
const normalized = category
|
||||
.toLowerCase()
|
||||
.replace(/[‘’‚‛“”„‟'"`]/g, '')
|
||||
.replace(/[‘’‚‛“”„‟''"`]/g, '')
|
||||
.trim();
|
||||
inDiscretion = DISCRETION_HEADINGS.has(normalized);
|
||||
continue;
|
||||
}
|
||||
const bulletMatch = line.match(bulletRe);
|
||||
if (bulletMatch) {
|
||||
|
||||
// Colon form: `- **D-NN[ [tags]]:** text`
|
||||
const colonMatch = line.match(bulletColonRe);
|
||||
if (colonMatch) {
|
||||
flush();
|
||||
const id = `D-${bulletMatch[1]}`;
|
||||
const tags = bulletMatch[2]
|
||||
? bulletMatch[2]
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
const id = `D-${colonMatch[1]}`;
|
||||
const tags = colonMatch[2]
|
||||
? colonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
||||
current = { id, text: bulletMatch[3], category, tags, trackable };
|
||||
current = { id, text: colonMatch[3], category, tags, trackable };
|
||||
continue;
|
||||
}
|
||||
// Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet
|
||||
// but failed `bulletRe` (e.g. a `:` or `*` inside the pre-colon run) must NOT
|
||||
// be silently dropped — a narrowed trackable set lets a blocking coverage gate
|
||||
// report a false pass. Surface it loudly instead.
|
||||
if (/^\s*-\s+\*\*D-/.test(line)) {
|
||||
// A malformed D-bullet still starts a (failed) new decision, so it ends the
|
||||
// previous one — flush before warning so a following continuation line cannot
|
||||
// be mis-appended to the prior valid decision.
|
||||
|
||||
// Em-dash form: `- **D-NN[ [tags]] — title** body`
|
||||
const emDashMatch = line.match(bulletEmDashRe);
|
||||
if (emDashMatch) {
|
||||
flush();
|
||||
const id = `D-${emDashMatch[1]}`;
|
||||
const tags = emDashMatch[2]
|
||||
? emDashMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
|
||||
: [];
|
||||
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
||||
// The body (emDashMatch[3]) may be empty for the pure title form; the
|
||||
// title itself is embedded in the bold run but we report the body as text
|
||||
// (consistent with how the gate cares only about coverage, not title/body split).
|
||||
current = { id, text: emDashMatch[3] || '', category, tags, trackable };
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
|
||||
// bullet but failed both patterns — flush, warn, and record the miss.
|
||||
// parseMisses > 0 forces could-not-parse even when other decisions parsed.
|
||||
if (/^\s*-\s+\*\*D-/.test(line)) {
|
||||
flush();
|
||||
parseMisses += 1;
|
||||
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line for current decision (indented with space OR tab,
|
||||
// non-bullet, non-empty) — tab indentation must work too (review F12).
|
||||
if (current && trimmed !== '' && !trimmed.startsWith('-') && /^[ \t]/.test(line)) {
|
||||
current.text += ' ' + trimmed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blank line or unrelated content terminates the current decision
|
||||
if (trimmed === '') {
|
||||
flush();
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
return { decisions: out, parseMisses };
|
||||
}
|
||||
|
||||
// ─── Primary entry point: extractDecisions ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract decisions from CONTEXT.md content with a typed outcome.
|
||||
*
|
||||
* Strategy (in priority order):
|
||||
* 1. If the content (fence-stripped) contains `<decisions>...</decisions>` blocks,
|
||||
* parse ONLY those blocks (canonical form; markdown-header content outside blocks
|
||||
* is ignored when a block is present — existing behavior preserved).
|
||||
* 2. Otherwise, look for a /decisions?/i heading and collect its section body.
|
||||
* This is the T1 recall fix for #1364.
|
||||
* 3. If neither is found, return outcome based on decision-shape heuristics.
|
||||
*/
|
||||
export function extractDecisions(content: unknown): DecisionExtraction {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { decisions: [], outcome: 'none-present' };
|
||||
}
|
||||
|
||||
// Apply fence-stripping for block extraction (prevents example blocks inside
|
||||
// ``` fences from polluting the parser — review F11).
|
||||
const { text: stripped, unterminatedFence } = stripFencedCode(content);
|
||||
|
||||
// ── Path 1: <decisions> blocks present ──────────────────────────────────────
|
||||
const taggedBlocks = extractTaggedBlocks(stripped, 'decisions');
|
||||
if (taggedBlocks.length > 0) {
|
||||
const combined = taggedBlocks.join('\n\n');
|
||||
const { decisions, parseMisses } = parseDecisionLines(combined);
|
||||
if (decisions.length > 0 && parseMisses === 0) {
|
||||
return { decisions, outcome: 'parsed' };
|
||||
}
|
||||
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
|
||||
if (parseMisses > 0) {
|
||||
return { decisions, outcome: 'could-not-parse' };
|
||||
}
|
||||
// FIX A: Block present but 0 extracted and no parse-misses.
|
||||
// Only report could-not-parse when there is genuine evidence of real decisions
|
||||
// that failed to parse: a \bD- token in the block text, or an unterminated fence.
|
||||
// An empty scaffold (<decisions></decisions>) or an all-prose block has no such
|
||||
// evidence — treat as none-present so the gate passes cleanly.
|
||||
const hasDecisionTokenInBlock = /\bD-[A-Za-z0-9]/m.test(combined);
|
||||
if (hasDecisionTokenInBlock || unterminatedFence) {
|
||||
return { decisions: [], outcome: 'could-not-parse' };
|
||||
}
|
||||
return { decisions: [], outcome: 'none-present' };
|
||||
}
|
||||
|
||||
// ── Path 2: markdown-header fallback (#1364 fix) ─────────────────────────────
|
||||
// Use the seam's collectSection to find a /decisions?/i heading section.
|
||||
// levelBounded:true → stop at next same-or-higher-level heading.
|
||||
// stripFences:true → inner fences inside the section body are stripped.
|
||||
const section = collectSection(
|
||||
content,
|
||||
(h) => /decisions?\b/i.test(h.text),
|
||||
{ levelBounded: true, stripFences: true },
|
||||
);
|
||||
|
||||
if (section !== null) {
|
||||
const { decisions, parseMisses } = parseDecisionLines(section.body);
|
||||
if (decisions.length > 0 && parseMisses === 0) {
|
||||
return { decisions, outcome: 'parsed' };
|
||||
}
|
||||
// FIX B: parse-misses present — could-not-parse even if some decisions extracted.
|
||||
if (parseMisses > 0) {
|
||||
return { decisions, outcome: 'could-not-parse' };
|
||||
}
|
||||
// FIX A: Heading found but 0 extracted and no parse-misses.
|
||||
// Only report could-not-parse when the section body contains a D- token.
|
||||
// A heading with only prose, sub-headings, or all-discretion content
|
||||
// (no trackable D- tokens) is a legitimate empty/discretion section → none-present.
|
||||
const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
|
||||
if (hasDecisionTokenInSection) {
|
||||
return { decisions: [], outcome: 'could-not-parse' };
|
||||
}
|
||||
return { decisions: [], outcome: 'none-present' };
|
||||
}
|
||||
|
||||
// ── Path 3: no blocks, no heading ────────────────────────────────────────────
|
||||
// Apply shape heuristics to distinguish none-present from could-not-parse.
|
||||
// We re-use the already-computed unterminatedFence and check for D- tokens.
|
||||
const hasDecisionToken = /\bD-[A-Za-z0-9]/m.test(stripped);
|
||||
if (unterminatedFence || hasDecisionToken) {
|
||||
return { decisions: [], outcome: 'could-not-parse' };
|
||||
}
|
||||
|
||||
return { decisions: [], outcome: 'none-present' };
|
||||
}
|
||||
|
||||
// ─── parseDecisions: thin delegate (backwards-compatible entry point) ─────────
|
||||
|
||||
/**
|
||||
* Parse trackable decisions from CONTEXT.md content.
|
||||
*
|
||||
* Thin delegate over extractDecisions — callers receive the decisions array
|
||||
* exactly as before; nothing breaks. Use extractDecisions directly when the
|
||||
* outcome enum is needed (e.g. for the fail-loud gate logic).
|
||||
*
|
||||
* Returns ALL D-NN decisions found (including non-trackable ones, with
|
||||
* `trackable: false`). Callers that only want the gate-enforced decisions
|
||||
* should filter `.filter(d => d.trackable)`.
|
||||
*/
|
||||
export function parseDecisions(content: unknown): Decision[] {
|
||||
return extractDecisions(content).decisions;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const { escapeRegex } = phaseId;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
|
||||
import { parseDecisions } from './decisions.cjs';
|
||||
import { parseDecisions, extractDecisions } from './decisions.cjs';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -235,7 +235,10 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
|
||||
const ctxFile = findContextMdIn(phaseDirFiles);
|
||||
const ctxPath = ctxFile ? path.join(absPhaseDir, ctxFile) : null;
|
||||
const ctxMd = ctxPath ? fs.readFileSync(ctxPath, 'utf-8') : '';
|
||||
const dItems: DecisionItem[] = parseDecisions(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
|
||||
|
||||
// Use extractDecisions so gap-checker can distinguish could-not-parse from none-present.
|
||||
const ctxExtraction = extractDecisions(ctxMd);
|
||||
const dItems: DecisionItem[] = ctxExtraction.decisions.map(d => ({ ...d, source: 'CONTEXT.md' }));
|
||||
|
||||
const items: Item[] = [...reqItems, ...dItems];
|
||||
|
||||
@@ -250,6 +253,42 @@ function runGapAnalysis(cwd: string, phaseDir: string, options: RunGapAnalysisOp
|
||||
}
|
||||
} catch { /* unreadable */ }
|
||||
|
||||
// FIX D (#1365): surface decision could-not-parse independently of whether
|
||||
// requirements items exist. Without this, a could-not-parse on decisions is
|
||||
// silently masked whenever REQUIREMENTS.md has ≥1 item — the mismatch must
|
||||
// appear in the report regardless of the requirements row count.
|
||||
if (ctxExtraction.outcome === 'could-not-parse') {
|
||||
const mismatchMsg = '## Post-Planning Gap Analysis\n\nextracted 0 of N — possible format mismatch in CONTEXT.md decisions block.\n';
|
||||
// If there are also requirement items, include them in the return with the
|
||||
// mismatch summary appended, so the caller still sees requirement coverage.
|
||||
if (items.length > 0) {
|
||||
const rows = sortRows([
|
||||
...detectCoverage(items, planText),
|
||||
...ghostReqIds.map(id => ({ source: 'REQUIREMENTS.md', item: id, status: 'Missing from REQUIREMENTS.md' })),
|
||||
]);
|
||||
const covered = rows.filter(r => r.status === 'Covered').length;
|
||||
const uncovered = rows.length - covered;
|
||||
const coverageSummary = uncovered === 0
|
||||
? `✓ All ${rows.length} items covered by plans`
|
||||
: `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
|
||||
return {
|
||||
enabled: true,
|
||||
rows,
|
||||
table: formatGapTable(rows) + '\n' + coverageSummary + '\n\n' + mismatchMsg,
|
||||
summary: coverageSummary + '; extracted 0 of N — possible format mismatch',
|
||||
counts: { total: rows.length, covered, uncovered },
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
rows: [],
|
||||
table: mismatchMsg,
|
||||
summary: 'extracted 0 of N — possible format mismatch',
|
||||
counts: { total: 0, covered: 0, uncovered: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// #1365: if no items at all, surface a clean no-check message.
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
enabled: true,
|
||||
|
||||
585
src/markdown-sectionizer.cts
Normal file
585
src/markdown-sectionizer.cts
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* Markdown Sectionizer — canonical markdown-structure parsing seam
|
||||
*
|
||||
* Pure functions, Node built-ins only (no external deps). String-in → value-out, no I/O.
|
||||
* Promoted from `uat-predicate.cts` `_stripFencedBlocks` (CommonMark-correct state machine)
|
||||
* and extended with heading tokenisation, section collection, and bullet iteration.
|
||||
*
|
||||
* ADR-1372 — T0 foundational seam. Migration tiers T1–T7 progressively adopt this seam.
|
||||
*
|
||||
* ADR-457 build-at-publish: compiled by tsc to gsd-core/bin/lib/markdown-sectionizer.cjs.
|
||||
*/
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Result of stripping fenced code blocks from markdown content. */
|
||||
export interface StripFencedResult {
|
||||
/** Content with all fenced code blocks removed (delimiters and body lines). */
|
||||
text: string;
|
||||
/**
|
||||
* True when the input contained an unterminated fence (EOF inside a fence).
|
||||
* Callers that wish to signal malformed input to the user should inspect this.
|
||||
*/
|
||||
unterminatedFence: boolean;
|
||||
}
|
||||
|
||||
/** An ATX heading extracted by `tokenizeHeadings`. */
|
||||
export interface HeadingToken {
|
||||
/** Heading depth: 1 = `#`, 2 = `##`, 3 = `###`, etc. */
|
||||
level: number;
|
||||
/** Heading text with surrounding whitespace trimmed. */
|
||||
text: string;
|
||||
/** 1-based line number of the heading in the original content. */
|
||||
line: number;
|
||||
/** Character (string-index) offset of the `#` character in the original content string. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** A collected markdown section (heading + body). */
|
||||
export interface Section {
|
||||
/** The heading that opened this section. */
|
||||
heading: HeadingToken;
|
||||
/** All lines between this heading and the next stop, joined by `\n`. */
|
||||
body: string;
|
||||
/**
|
||||
* Character (string-index) offset in the ORIGINAL content string where the
|
||||
* section body begins (first character after the heading line's trailing newline).
|
||||
* Populated by `collectSections` and `collectSection`.
|
||||
* Used by `replaceSection` for a clean pure splice.
|
||||
*
|
||||
* INVARIANT: `content.slice(bodyStart, bodyEnd) === body` for every Section
|
||||
* returned by `collectSection` and `collectSections`.
|
||||
*/
|
||||
bodyStart: number;
|
||||
/**
|
||||
* Character (string-index) offset in the ORIGINAL content string where the
|
||||
* section body ends (exclusive). Because `body` is `trimEnd()`-ed, this equals
|
||||
* `bodyStart + body.length` — NOT the start of the next heading line.
|
||||
*
|
||||
* INVARIANT: `content.slice(bodyStart, bodyEnd) === body`.
|
||||
* This guarantees `replaceSection(content, section, section.body) === content`.
|
||||
*/
|
||||
bodyEnd: number;
|
||||
}
|
||||
|
||||
/** Recognised bullet markers. */
|
||||
export type BulletMarker = 'dash' | 'checkbox-unchecked' | 'checkbox-checked' | 'numbered';
|
||||
|
||||
/** A single bullet item from `iterateBullets`. */
|
||||
export interface BulletItem {
|
||||
/** Which marker shape was recognised. */
|
||||
marker: BulletMarker;
|
||||
/** Full bullet text including all indented continuation lines, whitespace-trimmed. */
|
||||
text: string;
|
||||
/** Raw indentation prefix of the opening bullet line. */
|
||||
indent: string;
|
||||
/** Checkbox state — `true` for `[x]`, `false` for `[ ]`, `null` for non-checkbox. */
|
||||
checked: boolean | null;
|
||||
}
|
||||
|
||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||
|
||||
interface FenceState {
|
||||
char: '`' | '~';
|
||||
len: number;
|
||||
}
|
||||
|
||||
// ─── stripFencedCode ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* CommonMark-correct fenced-code-block stripper.
|
||||
*
|
||||
* Ported from `uat-predicate.cts` `_stripFencedBlocks` — the reference
|
||||
* implementation for the repo. DO NOT modify `uat-predicate.cts` (its
|
||||
* migration is T5); this is a tracked duplication until T5 lands.
|
||||
*
|
||||
* Rules:
|
||||
* - Opening delimiter: a line whose non-indent portion begins with ≥3 backticks
|
||||
* or tildes (≤3 leading spaces tolerated per CommonMark §4.5).
|
||||
* - Closing delimiter: same character, run length ≥ opening, no trailing
|
||||
* non-whitespace text.
|
||||
* - A tilde fence inside a backtick fence (or vice versa) is fence *content*,
|
||||
* not a closing delimiter — delimiter char must match.
|
||||
* - Both delimiter lines and all content lines are dropped from the output.
|
||||
* - CRLF-safe: trailing `\r` is stripped before delimiter matching; the kept
|
||||
* non-fence lines are returned as-is (including any `\r`).
|
||||
* - `unterminatedFence` signals EOF inside an open fence.
|
||||
*/
|
||||
export function stripFencedCode(content: string): StripFencedResult {
|
||||
if (typeof content !== 'string') {
|
||||
return { text: '', unterminatedFence: false };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const kept: string[] = [];
|
||||
let openFence: FenceState | null = null;
|
||||
|
||||
// Matches: optional indent (≤3 spaces per CommonMark), fence run, optional info string
|
||||
const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
// Strip trailing \r for delimiter matching (CRLF safety)
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const m = delimRe.exec(line);
|
||||
if (m) {
|
||||
const char = m[2][0] as '`' | '~';
|
||||
const len = m[2].length;
|
||||
const trailing = m[3];
|
||||
if (openFence === null) {
|
||||
// CommonMark §4.5: backtick fence info string must not contain a backtick.
|
||||
// If it does, this line is NOT a valid fence opener (treat as ordinary content).
|
||||
if (char === '`' && trailing.includes('`')) {
|
||||
kept.push(rawLine);
|
||||
continue;
|
||||
}
|
||||
// Opening delimiter — record fence state, drop this line
|
||||
openFence = { char, len };
|
||||
} else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
|
||||
// Closing delimiter (same char, sufficient length, no trailing content) — close and drop
|
||||
openFence = null;
|
||||
}
|
||||
// else: mismatched delimiter inside fence — treat as content, still drop (it's a fence line)
|
||||
continue; // all delimiter lines are dropped
|
||||
}
|
||||
|
||||
if (openFence === null) {
|
||||
kept.push(rawLine); // non-fence content: keep as-is (preserve original \r if any)
|
||||
}
|
||||
// Lines inside a fence are silently dropped
|
||||
}
|
||||
|
||||
return { text: kept.join('\n'), unterminatedFence: openFence !== null };
|
||||
}
|
||||
|
||||
// ─── tokenizeHeadings ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract all ATX headings from `content` in document order.
|
||||
*
|
||||
* Only headings OUTSIDE fenced code blocks are returned — `stripFencedCode` is
|
||||
* applied first so that a `## heading` inside a ``` fence is not tokenised.
|
||||
*
|
||||
* Each token records `{ level, text, line, offset }` where `offset` is relative
|
||||
* to the ORIGINAL `content` (before fence-stripping), enabling callers to use
|
||||
* `collectSection` on the original string.
|
||||
*/
|
||||
export function tokenizeHeadings(content: string): HeadingToken[] {
|
||||
if (typeof content !== 'string' || content.length === 0) return [];
|
||||
|
||||
// Strip fences first so headings inside code blocks are ignored.
|
||||
// We need the original line positions, so we map stripped-text line numbers
|
||||
// back to original by tracking which original lines survived stripping.
|
||||
const originalLines = content.split('\n');
|
||||
const tokens: HeadingToken[] = [];
|
||||
|
||||
// We re-run the fence state machine to know which lines are "kept", so we
|
||||
// can map line index in original to whether it survived.
|
||||
const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
||||
let openFence: FenceState | null = null;
|
||||
|
||||
// Accumulate character offset as we iterate lines
|
||||
let charOffset = 0;
|
||||
|
||||
for (let i = 0; i < originalLines.length; i++) {
|
||||
const rawLine = originalLines[i];
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
|
||||
const dm = delimRe.exec(line);
|
||||
if (dm) {
|
||||
const char = dm[2][0] as '`' | '~';
|
||||
const len = dm[2].length;
|
||||
const trailing = dm[3];
|
||||
if (openFence === null) {
|
||||
// CommonMark §4.5: backtick fence info string must not contain a backtick.
|
||||
if (char === '`' && trailing.includes('`')) {
|
||||
// Not a valid fence opener — check for heading on this line (will fall through)
|
||||
} else {
|
||||
openFence = { char, len };
|
||||
charOffset += rawLine.length + 1;
|
||||
continue;
|
||||
}
|
||||
} else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
|
||||
openFence = null;
|
||||
charOffset += rawLine.length + 1;
|
||||
continue;
|
||||
} else {
|
||||
// Mismatched/invalid delimiter inside fence — treat as content (still inside fence), skip heading check
|
||||
charOffset += rawLine.length + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (openFence === null) {
|
||||
// This line is outside any fence — check for ATX heading.
|
||||
// CommonMark: ≤3 leading spaces, then 1–6 `#`, then either EOF (empty heading)
|
||||
// or at least one space/tab followed by optional text, with optional closing `#` sequence.
|
||||
const headingMatch = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line);
|
||||
if (headingMatch) {
|
||||
const hashes = headingMatch[2];
|
||||
const rest = headingMatch[3] ?? '';
|
||||
// Strip optional closing `#` sequence: trailing whitespace + one or more `#` + optional whitespace
|
||||
const rawText = rest.replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '');
|
||||
tokens.push({
|
||||
level: hashes.length,
|
||||
text: rawText.trim(),
|
||||
line: i + 1, // 1-based
|
||||
offset: charOffset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
charOffset += rawLine.length + 1;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
// ─── collectSections ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect sections from `content`, calling `stopPredicate` on each heading to
|
||||
* decide where sections end.
|
||||
*
|
||||
* Returns an array of `Section` objects, one per matched heading. The `body`
|
||||
* of each section runs from the line after the heading up to (but not
|
||||
* including) the next heading that satisfies `stopPredicate`, or EOF.
|
||||
*
|
||||
* Unlike a greedy-regex approach, this is a line-by-line walk — compatible
|
||||
* with the repo's "line-by-line section collection" pattern.
|
||||
*/
|
||||
export function collectSections(
|
||||
content: string,
|
||||
stopPredicate: (heading: HeadingToken) => boolean,
|
||||
): Section[] {
|
||||
if (typeof content !== 'string' || content.length === 0) return [];
|
||||
|
||||
const headings = tokenizeHeadings(content);
|
||||
if (headings.length === 0) return [];
|
||||
|
||||
const lines = content.split('\n');
|
||||
const sections: Section[] = [];
|
||||
|
||||
// Build a set of line numbers (1-based) that are heading lines
|
||||
const headingsByLine = new Map<number, HeadingToken>();
|
||||
for (const h of headings) {
|
||||
headingsByLine.set(h.line, h);
|
||||
}
|
||||
|
||||
// Build a byte-offset table: lineOffsets[i] = byte offset of the start of line i+1 (1-based: i=0 → line 1)
|
||||
// The body of a section starts at the byte after the heading line's trailing '\n'.
|
||||
const lineOffsets: number[] = new Array<number>(lines.length);
|
||||
let acc = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
lineOffsets[i] = acc;
|
||||
acc += lines[i].length + 1; // +1 for the '\n' we split on
|
||||
}
|
||||
// lineOffsets[i] is the byte offset of line (i+1) (1-based). EOF sentinel:
|
||||
const eofOffset = acc; // === content.length + (content.endsWith('\n') ? 0 : 0) ≈ content.length
|
||||
|
||||
let currentHeading: HeadingToken | null = null;
|
||||
let currentBodyStart = 0;
|
||||
let bodyLines: string[] = [];
|
||||
|
||||
const flush = (_bodyEndOffset: number): void => {
|
||||
if (currentHeading !== null) {
|
||||
const rawBody = bodyLines.join('\n');
|
||||
const body = rawBody.trimEnd();
|
||||
// INVARIANT: content.slice(bodyStart, bodyEnd) === body
|
||||
// bodyEnd is derived from body.length, NOT from the raw separator offset,
|
||||
// so round-trips via replaceSection(content, section, section.body) are exact.
|
||||
sections.push({
|
||||
heading: currentHeading,
|
||||
body,
|
||||
bodyStart: currentBodyStart,
|
||||
bodyEnd: currentBodyStart + body.length,
|
||||
});
|
||||
currentHeading = null;
|
||||
bodyLines = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNo = i + 1; // 1-based
|
||||
const h = headingsByLine.get(lineNo);
|
||||
if (h !== undefined && stopPredicate(h)) {
|
||||
// This heading is a stop boundary — flush current section, start new one.
|
||||
// The body ends at the start of this heading line.
|
||||
flush(lineOffsets[i]);
|
||||
currentHeading = h;
|
||||
// Body starts at the beginning of the line AFTER the heading line
|
||||
const headingLineIdx = h.line - 1; // 0-based
|
||||
currentBodyStart = lineOffsets[headingLineIdx] + lines[headingLineIdx].length + 1;
|
||||
} else if (currentHeading !== null) {
|
||||
bodyLines.push(lines[i]);
|
||||
}
|
||||
}
|
||||
flush(eofOffset);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// ─── collectSection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect a single section whose heading satisfies `headingPredicate`.
|
||||
*
|
||||
* Options:
|
||||
* - `levelBounded` (default: `true`): the section ends at the next heading of
|
||||
* the same or higher level (lower level number = higher in the hierarchy).
|
||||
* When `false`, the section body runs until any heading or EOF.
|
||||
* Ignored when `stopAtLevel` is provided.
|
||||
* - `stopAtLevel` (optional): when provided, the section ends at the next heading
|
||||
* whose `level <= stopAtLevel`, regardless of the opener's level. This enables
|
||||
* modeling sections like a `##`-opened section that also stops at `###`
|
||||
* (pass `stopAtLevel: 3`). Takes precedence over `levelBounded` when set.
|
||||
* - `stripFences` (default: `false`): apply `stripFencedCode` to the body
|
||||
* before returning. The `heading` in the result always refers to the original
|
||||
* heading (pre-strip).
|
||||
*
|
||||
* Returns `null` when no matching heading is found.
|
||||
*/
|
||||
export function collectSection(
|
||||
content: string,
|
||||
headingPredicate: (heading: HeadingToken) => boolean,
|
||||
opts: { levelBounded?: boolean; stopAtLevel?: number; stripFences?: boolean } = {},
|
||||
): Section | null {
|
||||
if (typeof content !== 'string' || content.length === 0) return null;
|
||||
|
||||
const { levelBounded = true, stopAtLevel, stripFences = false } = opts;
|
||||
|
||||
const headings = tokenizeHeadings(content);
|
||||
const targetIdx = headings.findIndex(headingPredicate);
|
||||
if (targetIdx === -1) return null;
|
||||
|
||||
const target = headings[targetIdx];
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Determine which headings act as stops after the target
|
||||
const bodyStartLine = target.line + 1; // 1-based, first line of body
|
||||
let bodyEndLine = lines.length + 1; // 1-based, exclusive (default: EOF+1)
|
||||
|
||||
for (let j = targetIdx + 1; j < headings.length; j++) {
|
||||
const next = headings[j];
|
||||
let isStop: boolean;
|
||||
if (stopAtLevel !== undefined) {
|
||||
// stopAtLevel: stop at the next heading whose level <= stopAtLevel
|
||||
isStop = next.level <= stopAtLevel;
|
||||
} else {
|
||||
isStop = levelBounded ? next.level <= target.level : true;
|
||||
}
|
||||
if (isStop) {
|
||||
bodyEndLine = next.line; // stop before this line (1-based)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute character offsets for bodyStart.
|
||||
// lineOffsets[i] = character offset of line (i+1) in content (1-based).
|
||||
const lineOffsets: number[] = new Array<number>(lines.length);
|
||||
let acc = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
lineOffsets[i] = acc;
|
||||
acc += lines[i].length + 1; // +1 for the '\n' separator
|
||||
}
|
||||
const eofOffset = acc; // byte offset past the last line
|
||||
|
||||
// bodyStart: character offset of first line of body (bodyStartLine is 1-based)
|
||||
const bodyStartOffset = bodyStartLine <= lines.length ? lineOffsets[bodyStartLine - 1] : eofOffset;
|
||||
|
||||
// Slice body lines (0-based array: bodyStartLine-1 to bodyEndLine-2 inclusive)
|
||||
const bodyRaw = lines.slice(bodyStartLine - 1, bodyEndLine - 1).join('\n').trimEnd();
|
||||
const body = stripFences ? stripFencedCode(bodyRaw).text : bodyRaw;
|
||||
|
||||
// INVARIANT: content.slice(bodyStart, bodyEnd) === body
|
||||
// bodyEnd is derived from body.length so that replaceSection(content, section, section.body) === content.
|
||||
return { heading: target, body, bodyStart: bodyStartOffset, bodyEnd: bodyStartOffset + body.length };
|
||||
}
|
||||
|
||||
// ─── iterateBullets ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract bullet items from `sectionText`.
|
||||
*
|
||||
* Recognises three marker families:
|
||||
* - **Checkbox**: `- [ ] text` (unchecked) and `- [x] text` / `- [X] text` (checked)
|
||||
* - **Dash**: `- text`, `* text`, `+ text` (plain unordered list item)
|
||||
* - **Numbered**: `1. text`, `42. text` (ordered list item)
|
||||
*
|
||||
* Indented continuation lines (lines that are not themselves bullet openers and
|
||||
* have at least one leading space or tab) are accumulated into the current
|
||||
* bullet's `text`.
|
||||
*
|
||||
* Blank lines terminate the current bullet (consistent with CommonMark block
|
||||
* handling and the repo's existing bullet parsers).
|
||||
*/
|
||||
export function iterateBullets(sectionText: string): BulletItem[] {
|
||||
if (typeof sectionText !== 'string' || sectionText.length === 0) return [];
|
||||
|
||||
const lines = sectionText.split('\n');
|
||||
const items: BulletItem[] = [];
|
||||
|
||||
// Checkbox bullet: `<indent>- [ ] text` or `<indent>- [x] text`
|
||||
const checkboxRe = /^(\s*)- \[([xX ])\] (.*)$/;
|
||||
// Plain dash/asterisk/plus bullet: `<indent>- text`, `<indent>* text`, `<indent>+ text`
|
||||
const dashRe = /^(\s*)[-*+] (.*)$/;
|
||||
// Numbered bullet: `<indent>1. text`
|
||||
const numberedRe = /^(\s*)\d+\. (.*)$/;
|
||||
// Continuation: non-empty, indented, NOT a bullet opener
|
||||
const continuationRe = /^[ \t]/;
|
||||
|
||||
let current: BulletItem | null = null;
|
||||
|
||||
const flush = (): void => {
|
||||
if (current !== null) {
|
||||
current.text = current.text.trim();
|
||||
items.push(current);
|
||||
current = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
// Strip trailing \r (CRLF safety)
|
||||
const line = rawLine.replace(/\r$/, '');
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Blank line terminates current bullet
|
||||
if (trimmed === '') {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Checkbox bullet (checked or unchecked) — must test before dashRe
|
||||
const cbm = checkboxRe.exec(line);
|
||||
if (cbm) {
|
||||
flush();
|
||||
const stateChar = cbm[2];
|
||||
const checked = stateChar === 'x' || stateChar === 'X';
|
||||
current = {
|
||||
marker: checked ? 'checkbox-checked' : 'checkbox-unchecked',
|
||||
text: cbm[3],
|
||||
indent: cbm[1],
|
||||
checked,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbered bullet
|
||||
const nm = numberedRe.exec(line);
|
||||
if (nm) {
|
||||
flush();
|
||||
current = {
|
||||
marker: 'numbered',
|
||||
text: nm[2],
|
||||
indent: nm[1],
|
||||
checked: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain dash / asterisk / plus bullet
|
||||
const dm = dashRe.exec(line);
|
||||
if (dm) {
|
||||
flush();
|
||||
current = {
|
||||
marker: 'dash',
|
||||
text: dm[2],
|
||||
indent: dm[1],
|
||||
checked: null,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Continuation line (indented, non-bullet) — append to current bullet
|
||||
if (current !== null && continuationRe.test(line)) {
|
||||
current.text += ' ' + trimmed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Non-bullet, non-continuation line (e.g. a paragraph, heading) — flush
|
||||
flush();
|
||||
}
|
||||
flush();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
// ─── extractTaggedBlocks ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return the inner text of every `<tagName>…</tagName>` block in `content`,
|
||||
* in document order.
|
||||
*
|
||||
* Designed for extracting structured XML-like annotation blocks that live in
|
||||
* markdown prose (e.g. `<decisions>…</decisions>`, `<requirements>…</requirements>`).
|
||||
* Returns `[]` when no matching blocks are found.
|
||||
*
|
||||
* The `tagName` argument is regex-escaped, so names that contain regex
|
||||
* metacharacters (e.g. `foo.bar`, `my+tag`) are matched literally.
|
||||
*
|
||||
* **Input contract:** the caller decides whether to pass raw or fence-stripped
|
||||
* content. `extractTaggedBlocks` is a pure block extractor — it does NOT strip
|
||||
* fenced code blocks itself. If a `<tagName>` block appears inside a fenced code
|
||||
* block and should be excluded, the caller should apply `stripFencedCode` first.
|
||||
*
|
||||
* **Nested tags are NOT supported.** The underlying regex uses a non-greedy
|
||||
* `[\s\S]*?` match, which means it closes at the FIRST `</tagName>` encountered.
|
||||
* Given `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns
|
||||
* `['<x>inner']` — the inner `<x>` is captured as literal text, and the second
|
||||
* `</x>` is left unmatched (or matched as a second block with empty inner text
|
||||
* if another `<x>` follows). Callers that need to handle nested tags must
|
||||
* pre-process the input or use a proper XML/HTML parser.
|
||||
*
|
||||
* Generalises `decisions.cts`'s bespoke `matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)`
|
||||
* so tier T1 can drop its own copy (tracked duplication until T1 lands).
|
||||
*/
|
||||
export function extractTaggedBlocks(content: string, tagName: string): string[] {
|
||||
if (typeof content !== 'string' || content.length === 0) return [];
|
||||
if (typeof tagName !== 'string' || tagName.length === 0) return [];
|
||||
|
||||
// Escape the tag name for safe interpolation into a RegExp.
|
||||
const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g');
|
||||
|
||||
const results: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
results.push(match[1]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── replaceSection ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Splice `newBody` in place of a section's body and return the resulting
|
||||
* full content string.
|
||||
*
|
||||
* Uses the `bodyStart`/`bodyEnd` character offsets carried by the `Section`
|
||||
* type to perform a pure string splice — no regex, no line-counting. The
|
||||
* heading is preserved verbatim; only the bytes between `bodyStart` and
|
||||
* `bodyEnd` are replaced.
|
||||
*
|
||||
* The `newBody` is inserted as-is between `content.slice(0, bodyStart)` and
|
||||
* `content.slice(bodyEnd)`. If `newBody` should end with a trailing newline
|
||||
* before the next section's heading, the caller is responsible for including
|
||||
* it (consistent with how `trimEnd()` is applied to collected bodies — see
|
||||
* `collectSections`/`collectSection`).
|
||||
*
|
||||
* Typical read-modify-write pattern (T6 state.cts use case):
|
||||
* ```
|
||||
* const section = collectSection(content, h => h.text === 'Name');
|
||||
* if (section) {
|
||||
* content = replaceSection(content, section, newBody);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* CRLF-safe: the splice is purely character-offset-based, so CRLF sequences
|
||||
* are preserved in the surrounding content unchanged.
|
||||
*/
|
||||
export function replaceSection(content: string, section: Section, newBody: string): string {
|
||||
if (typeof content !== 'string') return content;
|
||||
if (typeof newBody !== 'string') return content;
|
||||
return content.slice(0, section.bodyStart) + newBody + content.slice(section.bodyEnd);
|
||||
}
|
||||
|
||||
// Consumers: require('../gsd-core/bin/lib/markdown-sectionizer.cjs')
|
||||
// Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
|
||||
@@ -211,6 +211,14 @@ describe('agent-skills command', () => {
|
||||
const r = runAgentSkillsJson(['agent-skills', 'gsd-executor'], tmpDir);
|
||||
assert.ok(r.success, 'Command should succeed even with missing skill paths');
|
||||
assert.strictEqual(r.ir.block, '', 'block must be empty when all skill paths are missing');
|
||||
// The --json IR carries a warnings[] field (#1374): a skipped path must not
|
||||
// be dropped silently. Assert it names the missing path so this test guards
|
||||
// the silent-drop regression, not merely the empty block.
|
||||
assert.ok(Array.isArray(r.ir.warnings), 'IR must include a warnings array');
|
||||
assert.ok(
|
||||
r.ir.warnings.some((w) => w.includes('skills/nonexistent')),
|
||||
`warnings must name the skipped path, got: ${JSON.stringify(r.ir.warnings)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('validates path safety — rejects traversal attempts', () => {
|
||||
@@ -226,11 +234,12 @@ describe('agent-skills command', () => {
|
||||
|
||||
test('returns typed empty IR when no agent type argument provided', () => {
|
||||
const r = runAgentSkillsJson(['agent-skills'], tmpDir);
|
||||
// With --json and no agent type, the command outputs the empty-string IR
|
||||
assert.ok(r.success, 'Command should succeed');
|
||||
// Output is JSON, either empty string or empty object
|
||||
const parsed = JSON.parse(r.success ? JSON.stringify(r.ir) : '""');
|
||||
assert.ok(parsed === '' || (typeof parsed === 'object'), 'Should return empty or empty-agent IR');
|
||||
// With --json and no agent type, cmdAgentSkills calls output('', raw, ''),
|
||||
// so the IR is the JSON-encoded empty string "" which parses to ''. Pin that
|
||||
// exact contract: the old assertion (=== '' || typeof === 'object') passed
|
||||
// even for a null IR because typeof null === 'object', so it guarded nothing.
|
||||
assert.strictEqual(r.ir, '', 'empty IR must be the empty string when no agent type is provided');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
793
tests/decisions.test.cjs
Normal file
793
tests/decisions.test.cjs
Normal file
@@ -0,0 +1,793 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* decisions.test.cjs — regression tests for parseDecisions / extractDecisions
|
||||
* and the check.decision-coverage-plan gate fail-loud behavior.
|
||||
*
|
||||
* Bug #1364: parseDecisions returns [] when decisions appear under markdown headers
|
||||
* (## Locked decisions / ## Implementation decisions) instead of a
|
||||
* <decisions>...</decisions> block. Also, em-dash bullets
|
||||
* '- **D-1 — title** body' are dropped as unparseable.
|
||||
*
|
||||
* Bug #1365: check.decision-coverage-plan silently returns passed:true when
|
||||
* CONTEXT.md is decision-shaped (has <decisions> block or D- tokens) but 0
|
||||
* decisions are extracted — gate now returns passed:false with format-mismatch
|
||||
* reason (could-not-parse outcome).
|
||||
*
|
||||
* Parser QA matrix (CONTRIBUTING.md 'Parser and project-file inputs'):
|
||||
* - CRLF newlines
|
||||
* - Unicode in a heading
|
||||
* - Decisions-looking heading inside a fenced code block (must be ignored)
|
||||
* - Both bullet forms: colon ('- **D-1:** ...') and em-dash ('- **D-1 — ...**')
|
||||
* - Genuinely empty / no-decisions case (still [])
|
||||
* - Pre-existing <decisions> block behaviour is unaffected (regression guard)
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { parseDecisions, extractDecisions } = require('../gsd-core/bin/lib/decisions.cjs');
|
||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Regression #1364: markdown-header fallback ───────────────────────────────
|
||||
|
||||
describe('parseDecisions — markdown header fallback (#1364)', () => {
|
||||
test('extracts D-NN from ## Locked decisions header (em-dash bullets)', () => {
|
||||
const md = '## Locked decisions\n- **D-1 — a** x\n- **D-2 — b** y\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(
|
||||
ds.map(d => d.id),
|
||||
['D-1', 'D-2'],
|
||||
'should extract D-1 and D-2 from em-dash bullets under markdown header'
|
||||
);
|
||||
});
|
||||
|
||||
test('extracts D-NN from ## Implementation decisions header (colon bullets)', () => {
|
||||
const md = '## Implementation decisions\n- **D-01:** Use OAuth 2.0\n- **D-02:** Redis sessions\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02']);
|
||||
assert.strictEqual(ds[0].text, 'Use OAuth 2.0');
|
||||
});
|
||||
|
||||
test('extracts D-NN from ### Decisions header (mixed bullets)', () => {
|
||||
const md = '### Decisions\n- **D-1:** colon form\n- **D-2 — em-dash form** body text\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-1', 'D-2']);
|
||||
});
|
||||
|
||||
test('extracts from header with case variation (## DECISIONS)', () => {
|
||||
const md = '## DECISIONS\n- **D-10:** uppercase heading\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-10']);
|
||||
});
|
||||
|
||||
test('extracts from heading with Unicode in surrounding text (## \u{1F512} Locked decisions)', () => {
|
||||
// Unicode chars before "decisions" must not break the heading matcher.
|
||||
const md = '## \u{1F512} Locked decisions\n- **D-3 — unicode heading** value\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-3']);
|
||||
});
|
||||
|
||||
test('CRLF newlines work for markdown-header path', () => {
|
||||
const md = '## Locked decisions\r\n- **D-5:** crlf bullet\r\n- **D-6 — em dash** crlf em\r\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-5', 'D-6']);
|
||||
});
|
||||
|
||||
test('decisions-looking heading inside a fenced code block is ignored', () => {
|
||||
const md = [
|
||||
'```',
|
||||
'## Locked decisions',
|
||||
'- **D-99:** fake',
|
||||
'```',
|
||||
'',
|
||||
'## Real decisions',
|
||||
'- **D-1:** real',
|
||||
].join('\n');
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-1']);
|
||||
});
|
||||
|
||||
test('generic prose heading does not produce false positives', () => {
|
||||
const md = '## Context\n- some bullet\n\n## Architecture\n- another bullet\n';
|
||||
assert.deepStrictEqual(parseDecisions(md), []);
|
||||
});
|
||||
|
||||
test('no decisions anywhere returns [] (no false positives)', () => {
|
||||
assert.deepStrictEqual(parseDecisions('## Locked decisions\n\nNo bullets here.\n'), []);
|
||||
});
|
||||
|
||||
test('content with no decisions heading and no block returns []', () => {
|
||||
assert.deepStrictEqual(parseDecisions('# Just a title\nsome prose\n'), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regression #1364: em-dash bullet inside existing <decisions> block ───────
|
||||
|
||||
describe('parseDecisions — em-dash bullet form inside <decisions> block (#1364)', () => {
|
||||
test('em-dash bullet is parsed inside a <decisions> block', () => {
|
||||
const md = '<decisions>\n- **D-1 — my title** body text\n</decisions>\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-1']);
|
||||
assert.ok(ds[0].text.length > 0, 'text must not be empty');
|
||||
});
|
||||
|
||||
test('em-dash bullet with alphanumeric ID is parsed', () => {
|
||||
const md = '<decisions>\n- **D-INFRA-01 — infra decision** body\n</decisions>\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-INFRA-01']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regression guard: pre-existing <decisions> block behaviour unchanged ─────
|
||||
|
||||
describe('parseDecisions — existing <decisions> block still works (#1364 guard)', () => {
|
||||
test('colon form inside <decisions> block still parses', () => {
|
||||
const md = '<decisions>\n- **D-1:** colon form\n</decisions>\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-1']);
|
||||
assert.strictEqual(ds[0].text, 'colon form');
|
||||
});
|
||||
|
||||
test('multiple D-NN in block with categories still works', () => {
|
||||
const md = `<decisions>\n### Auth\n- **D-01:** OAuth\n### Storage\n- **D-02:** Postgres\n</decisions>\n`;
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01', 'D-02']);
|
||||
assert.strictEqual(ds[0].category, 'Auth');
|
||||
});
|
||||
|
||||
test('D-IDs outside the block are still ignored when a block is present', () => {
|
||||
const md = '- **D-99:** outside\n<decisions>\n- **D-01:** inside\n</decisions>\n- **D-77:** after\n';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
|
||||
});
|
||||
|
||||
test('empty / null / undefined still return []', () => {
|
||||
assert.deepStrictEqual(parseDecisions(''), []);
|
||||
assert.deepStrictEqual(parseDecisions(null), []);
|
||||
assert.deepStrictEqual(parseDecisions(undefined), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractDecisions outcome: 'none-present' and 'could-not-parse' ──────────
|
||||
|
||||
describe('extractDecisions — typed outcome (#1364 + #1365)', () => {
|
||||
test('returns outcome:parsed with decisions array when block present', () => {
|
||||
const md = '<decisions>\n- **D-1:** OAuth 2.0\n</decisions>\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'parsed');
|
||||
assert.strictEqual(result.decisions.length, 1);
|
||||
assert.strictEqual(result.decisions[0].id, 'D-1');
|
||||
});
|
||||
|
||||
test('returns outcome:parsed for markdown-header path', () => {
|
||||
const md = '## Locked decisions\n- **D-2:** use Redis\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'parsed');
|
||||
assert.strictEqual(result.decisions.length, 1);
|
||||
});
|
||||
|
||||
test('returns outcome:none-present for genuinely empty content', () => {
|
||||
const result = extractDecisions('# Just a title\nsome prose without decisions\n');
|
||||
assert.strictEqual(result.outcome, 'none-present');
|
||||
assert.deepStrictEqual(result.decisions, []);
|
||||
});
|
||||
|
||||
test('returns outcome:none-present for empty string', () => {
|
||||
const result = extractDecisions('');
|
||||
assert.strictEqual(result.outcome, 'none-present');
|
||||
});
|
||||
|
||||
test('returns outcome:could-not-parse when <decisions> block present but yields 0 decisions', () => {
|
||||
// A <decisions> block with no parseable bullets is decision-shaped
|
||||
const md = '<decisions>\n\nJust prose, no D-NN bullets\n\n</decisions>\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'could-not-parse');
|
||||
assert.deepStrictEqual(result.decisions, []);
|
||||
});
|
||||
|
||||
test('returns outcome:could-not-parse when D- token present but no parseable decisions', () => {
|
||||
// Content references D-01 in prose but it's malformed — not in a parseable bullet
|
||||
const md = '# Context\n\nSee also D-01 for background. No block, no heading.\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'could-not-parse');
|
||||
assert.deepStrictEqual(result.decisions, []);
|
||||
});
|
||||
|
||||
test('returns outcome:could-not-parse when /decisions?/i heading present but 0 decisions extracted', () => {
|
||||
// Header present but no actual D-NN bullets under it
|
||||
const md = '## Locked decisions\n\nNo D-NN bullets here, just prose.\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'could-not-parse');
|
||||
assert.deepStrictEqual(result.decisions, []);
|
||||
});
|
||||
|
||||
test('returns outcome:none-present for generic prose with no decision signals', () => {
|
||||
// No block, no /decisions?/i heading, no \bD- token — genuinely no decisions
|
||||
const md = '## Context\n\nSome architecture notes.\n\n## Goals\n\nBe fast.\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'none-present');
|
||||
});
|
||||
|
||||
test('parseDecisions delegates correctly (thin wrapper)', () => {
|
||||
// parseDecisions is a thin delegate that returns extractDecisions().decisions
|
||||
const md = '<decisions>\n- **D-1:** foo\n</decisions>\n';
|
||||
const fromExtract = extractDecisions(md).decisions;
|
||||
const fromParse = parseDecisions(md);
|
||||
assert.deepStrictEqual(fromParse, fromExtract);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── QA matrix for parser correctness ────────────────────────────────────────
|
||||
|
||||
describe('parseDecisions — parser QA matrix', () => {
|
||||
test('### category headings inside a decisions block set category', () => {
|
||||
const md = '<decisions>\n### Auth\n- **D-01:** OAuth 2.0\n### Storage\n- **D-02:** Postgres\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].category, 'Auth');
|
||||
assert.strictEqual(ds[1].category, 'Storage');
|
||||
});
|
||||
|
||||
test("### Claude's Discretion section sets trackable:false", () => {
|
||||
const md = "<decisions>\n### Claude's Discretion\n- **D-01:** internal\n</decisions>";
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('[informational] tag sets trackable:false', () => {
|
||||
const md = '<decisions>\n- **D-01 [informational]:** ref only\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('[deferred] tag sets trackable:false', () => {
|
||||
const md = '<decisions>\n- **D-01 [deferred]:** not yet\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds[0].trackable, false);
|
||||
});
|
||||
|
||||
test('continuation lines append to text (tab-indented)', () => {
|
||||
const md = '<decisions>\n- **D-01:** first line\n\tcontinued here\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.ok(ds[0].text.includes('first line'), 'must include first line');
|
||||
assert.ok(ds[0].text.includes('continued here'), 'must include continuation');
|
||||
});
|
||||
|
||||
test('CRLF inside a <decisions> block still parses', () => {
|
||||
const md = '<decisions>\r\n- **D-01:** crlf decision\r\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
|
||||
});
|
||||
|
||||
test('fenced code block inside document does not pollute decisions', () => {
|
||||
const md = [
|
||||
'```',
|
||||
'<decisions>',
|
||||
'- **D-99:** fake in fence',
|
||||
'</decisions>',
|
||||
'```',
|
||||
'',
|
||||
'<decisions>',
|
||||
'- **D-01:** real',
|
||||
'</decisions>',
|
||||
].join('\n');
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
|
||||
});
|
||||
|
||||
test('alphanumeric IDs (D-INFRA-01) are accepted', () => {
|
||||
const md = '<decisions>\n- **D-INFRA-01:** infra call\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-INFRA-01']);
|
||||
});
|
||||
|
||||
test('em-dash bullet form with tags still sets tags', () => {
|
||||
const md = '<decisions>\n- **D-01 [informational] — title** body\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.deepStrictEqual(ds.map(d => d.id), ['D-01']);
|
||||
assert.ok(ds[0].tags.includes('informational'));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1365: fail-loud gate — check.decision-coverage-plan ────────────────────
|
||||
|
||||
/**
|
||||
* Gate-level tests for the could-not-parse fail-loud behavior (#1365).
|
||||
* These exercise cmdDecisionCoveragePlan via the real CLI (check decision-coverage-plan).
|
||||
*
|
||||
* Naming: check.decision-coverage-plan is invoked as `query check.decision-coverage-plan`.
|
||||
* The gate lives in check-command-router.cts; outcome flows from decisions.cts extractDecisions.
|
||||
*/
|
||||
|
||||
function writeContextFile(phaseDir, content) {
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), content);
|
||||
}
|
||||
|
||||
function writePlanFile(phaseDir, name, body) {
|
||||
fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body);
|
||||
}
|
||||
|
||||
function writePlanningConfig(planningDir, config) {
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config));
|
||||
}
|
||||
|
||||
function runDecisionCoveragePlan(phaseDir, contextPath, cwd) {
|
||||
return runGsdTools(['query', 'check.decision-coverage-plan', phaseDir, contextPath], cwd);
|
||||
}
|
||||
|
||||
describe('check.decision-coverage-plan — fail-loud on could-not-parse (#1365)', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-1365-');
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
phaseDir = path.join(planningDir, 'phases', '01-init');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('decision-shaped CONTEXT.md with <decisions> block but 0 parseable decisions → passed:false (not silent skip)', () => {
|
||||
// #1365 bug: gate used to return passed:true/skipped for this case.
|
||||
writeContextFile(phaseDir, [
|
||||
'# Phase 1',
|
||||
'',
|
||||
'<decisions>',
|
||||
'',
|
||||
'See the ADR for architecture choices. No D-NN bullets here.',
|
||||
'',
|
||||
'</decisions>',
|
||||
].join('\n'));
|
||||
writePlanFile(phaseDir, '01', '# Plan\n## Objective\nImplement feature.\n');
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, false,
|
||||
`Gate must return passed:false for decision-shaped but 0-extracted content. Got: ${JSON.stringify(parsed)}`);
|
||||
const msg = (parsed.message || parsed.reason || '').toLowerCase();
|
||||
assert.ok(
|
||||
msg.includes('format') || msg.includes('mismatch') || msg.includes('could not parse') || msg.includes('parse'),
|
||||
`Message must mention format mismatch or parsing issue. Got: "${parsed.message}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('CONTEXT.md with \\bD- token in prose but no parseable decisions → passed:false', () => {
|
||||
writeContextFile(phaseDir, [
|
||||
'# Phase 1 Context',
|
||||
'',
|
||||
'See D-01 for the authentication decision and D-02 for storage.',
|
||||
'These are just prose references, not structured decisions.',
|
||||
].join('\n'));
|
||||
writePlanFile(phaseDir, '01', '# Plan\nRef D-01.\n');
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, false,
|
||||
`Gate must return passed:false for D-token-but-no-parseable content. Got: ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
|
||||
test('genuinely empty CONTEXT.md (no decision signals) → passed:true/skipped (no false alarm)', () => {
|
||||
writeContextFile(phaseDir, [
|
||||
'# Phase 1 Context',
|
||||
'',
|
||||
'## Goals',
|
||||
'Build the feature.',
|
||||
'',
|
||||
'## Architecture',
|
||||
'Use Node.js and TypeScript.',
|
||||
].join('\n'));
|
||||
writePlanFile(phaseDir, '01', '# Plan\nImplement the feature.\n');
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, true,
|
||||
`Gate must NOT false-alarm on genuinely empty content. Got: ${JSON.stringify(parsed)}`);
|
||||
assert.strictEqual(parsed.skipped, true,
|
||||
`Gate must skip when there are no decisions. Got: ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
|
||||
test('well-formed CONTEXT.md with real decisions all covered → passed:true (normal case)', () => {
|
||||
writeContextFile(phaseDir, [
|
||||
'# Context',
|
||||
'',
|
||||
'<decisions>',
|
||||
'### Implementation',
|
||||
'- **D-01:** Use OAuth 2.0 for authentication',
|
||||
'</decisions>',
|
||||
].join('\n'));
|
||||
writePlanFile(phaseDir, '01', '# Plan\n## Must Haves\n- D-01: Implement OAuth 2.0\n');
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, true,
|
||||
`Real decisions covered → must pass. Got: ${JSON.stringify(parsed)}`);
|
||||
assert.strictEqual(parsed.skipped, false);
|
||||
});
|
||||
|
||||
test('well-formed CONTEXT.md with decisions heading (markdown-header) all covered → passed:true', () => {
|
||||
// After #1364 fix: markdown-header decisions are now extractable and coverable
|
||||
writeContextFile(phaseDir, [
|
||||
'# Context',
|
||||
'',
|
||||
'## Implementation decisions',
|
||||
'',
|
||||
'- **D-01:** Use Redis for caching',
|
||||
].join('\n'));
|
||||
writePlanFile(phaseDir, '01', '# Plan\n## Must Haves\n- D-01: Implement Redis caching\n');
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, true,
|
||||
`Markdown-header decisions covered → must pass. Got: ${JSON.stringify(parsed)}`);
|
||||
assert.strictEqual(parsed.skipped, false);
|
||||
assert.strictEqual(parsed.total, 1);
|
||||
assert.strictEqual(parsed.covered, 1);
|
||||
});
|
||||
|
||||
test('CONTEXT.md missing → passed:true/skipped (unchanged behavior)', () => {
|
||||
const contextPath = path.join(phaseDir, 'NONEXISTENT-CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, true);
|
||||
assert.strictEqual(parsed.skipped, true);
|
||||
});
|
||||
|
||||
test('gate disabled by config → passed:true/skipped (unchanged behavior)', () => {
|
||||
writeContextFile(phaseDir, '<decisions>\nNo D-NN bullets\n</decisions>');
|
||||
writePlanningConfig(planningDir, { workflow: { context_coverage_gate: false } });
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const raw = result.output || '';
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.strictEqual(parsed.passed, true);
|
||||
assert.strictEqual(parsed.skipped, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check.decision-coverage-plan — boundary/threshold tests (#1365)', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-1365-bva-');
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
phaseDir = path.join(planningDir, 'phases', '01-init');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('exactly 1 decision extracted (limit == 1) → not could-not-parse', () => {
|
||||
writeContextFile(phaseDir, '<decisions>\n- **D-01:** single decision\n</decisions>');
|
||||
writePlanFile(phaseDir, '01', '# Plan\n## Objective\nRef D-01.\n');
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const parsed = JSON.parse(result.output || '');
|
||||
assert.strictEqual(parsed.passed, true);
|
||||
assert.strictEqual(parsed.skipped, false);
|
||||
assert.strictEqual(parsed.total, 1);
|
||||
assert.strictEqual(parsed.covered, 1);
|
||||
});
|
||||
|
||||
test('FIX A: empty <decisions></decisions> scaffold (limit - 1 == 0, no D- token) → none-present → passed:true/skipped (NOT blocked)', () => {
|
||||
// FIX A: An empty scaffold has no D- tokens → none-present, gate passes.
|
||||
// REGRESSION: previously returned could-not-parse → passed:false, blocking legitimate phases.
|
||||
writeContextFile(phaseDir, '<decisions>\n\n</decisions>');
|
||||
writePlanFile(phaseDir, '01', '# Plan\nSome plan.\n');
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const parsed = JSON.parse(result.output || '');
|
||||
assert.strictEqual(parsed.passed, true,
|
||||
`Empty scaffold → none-present → passed:true. Got: ${JSON.stringify(parsed)}`);
|
||||
assert.strictEqual(parsed.skipped, true,
|
||||
`Empty scaffold → none-present → skipped:true. Got: ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
|
||||
test('FIX A: <decisions> block with D- token in prose (not a bullet) → could-not-parse → passed:false', () => {
|
||||
// If the block contains a D- token but not as a parseable bullet → could-not-parse
|
||||
writeContextFile(phaseDir, '<decisions>\nD-01 is mentioned in prose but not as a bullet.\n</decisions>');
|
||||
writePlanFile(phaseDir, '01', '# Plan\nSome plan.\n');
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const parsed = JSON.parse(result.output || '');
|
||||
assert.strictEqual(parsed.passed, false,
|
||||
`D-token-in-prose → could-not-parse → passed:false. Got: ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX A regressions: tighten could-not-parse (empty scaffold / none-present) ───
|
||||
|
||||
describe('FIX A: tighten could-not-parse — empty scaffolds must not block (#1372)', () => {
|
||||
test('empty <decisions></decisions> scaffold → none-present (gate clean)', () => {
|
||||
// REGRESSION: previously returned could-not-parse, blocking legitimate phases
|
||||
const result = extractDecisions('<decisions></decisions>');
|
||||
assert.strictEqual(result.outcome, 'none-present',
|
||||
`Empty scaffold must be none-present. Got: ${result.outcome}`);
|
||||
assert.deepStrictEqual(result.decisions, []);
|
||||
});
|
||||
|
||||
test('## Decisions heading with prose only, no D- bullets → none-present', () => {
|
||||
// A heading with only prose and no D- tokens is not decision-shaped
|
||||
const md = '## Decisions\n\nArchitecture is handled via ADR-001.\n\nSee docs.\n';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'none-present',
|
||||
`Prose-only decisions heading must be none-present. Got: ${result.outcome}`);
|
||||
});
|
||||
|
||||
test('all-discretion block (### Claude’s Discretion, no D- bullets) → none-present', () => {
|
||||
// An all-discretion block with no D- tokens is a legitimate empty context
|
||||
const curlySingle = '’';
|
||||
const md = '<decisions>\n### Claude' + curlySingle + 's Discretion\n\nAll implementation details left to Claude.\n</decisions>';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'none-present',
|
||||
`All-discretion block with no D- bullets must be none-present. Got: ${result.outcome}`);
|
||||
});
|
||||
|
||||
test('<decisions> block with D- token in prose (not bullet) → still could-not-parse', () => {
|
||||
// A D- token that is NOT in a parseable bullet format still signals format mismatch
|
||||
const md = '<decisions>\nSee D-01 for the decision.\n</decisions>';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'could-not-parse',
|
||||
`D-token in block prose must be could-not-parse. Got: ${result.outcome}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX B regressions: parse-miss must fail loud ────────────────────────────
|
||||
|
||||
describe('FIX B: parse-miss on malformed D-NN bullet → could-not-parse (#1372)', () => {
|
||||
test('valid D-01 + malformed D-02 bullet → outcome could-not-parse (not silent pass)', () => {
|
||||
// REGRESSION: previously returned outcome:parsed (silently dropped D-02)
|
||||
const md = '<decisions>\n- **D-01:** Use OAuth 2.0\n- **D-02 malformed no colon or dash** text\n</decisions>';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'could-not-parse',
|
||||
`Mixed valid+malformed must be could-not-parse. Got: ${result.outcome}`);
|
||||
});
|
||||
|
||||
test('valid D-01 + malformed D-02 bullet → gate passed:false (not silent skip)', () => {
|
||||
// Gate-level regression: a parse-miss must propagate as passed:false
|
||||
// Uses extractDecisions directly to confirm gate-layer behavior
|
||||
const md = '<decisions>\n- **D-01:** Use OAuth 2.0\n- **D-02 malformed no colon or dash** text\n</decisions>';
|
||||
const result = extractDecisions(md);
|
||||
// The check-command-router uses outcome === 'could-not-parse' && decisions.length where
|
||||
// trackable.length === 0 → passed:false. Confirm outcome propagates correctly.
|
||||
assert.strictEqual(result.outcome, 'could-not-parse');
|
||||
// D-01 was parsed (it was valid); the result still contains it for context
|
||||
// but the overall outcome is could-not-parse because of the parse-miss on D-02.
|
||||
assert.ok(result.decisions.some(d => d.id === 'D-01'),
|
||||
`D-01 (valid) must still be in decisions. Got: ${JSON.stringify(result.decisions)}`);
|
||||
});
|
||||
|
||||
test('only malformed D-NN bullet (no valid ones) → could-not-parse', () => {
|
||||
const md = '<decisions>\n- **D-01 no colon no dash here** just text\n</decisions>';
|
||||
const result = extractDecisions(md);
|
||||
assert.strictEqual(result.outcome, 'could-not-parse',
|
||||
`Only-malformed-bullet must be could-not-parse. Got: ${result.outcome}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX B gate-level: parse-miss silently swallowed when covered decision exists ─
|
||||
|
||||
describe('FIX B gate-level: parse-miss → passed:false regardless of covered decisions (#1365)', () => {
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-1365-fixb-');
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
phaseDir = path.join(planningDir, 'phases', '01-init');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('FAIL-FIRST: valid D-01 covered + malformed D-02 → gate must return passed:false (parse-miss wins)', () => {
|
||||
// CONTEXT.md: D-01 is valid colon-form; D-02 has no colon and no em-dash → parse-miss
|
||||
// PLAN.md: covers D-01 via ## Must Haves so coverage of D-01 would pass on its own.
|
||||
// Before fix: decisions.length === 1 (D-01), outcome === 'could-not-parse' →
|
||||
// guard `decisions.length === 0 && outcome === 'could-not-parse'` is FALSE →
|
||||
// gate proceeds to coverage → D-01 is covered → passed:true [BUG]
|
||||
// After fix: outcome === 'could-not-parse' fires regardless of decisions.length →
|
||||
// gate returns passed:false with reason:'could-not-parse' [CORRECT]
|
||||
writeContextFile(phaseDir, [
|
||||
'# Phase 1 Context',
|
||||
'',
|
||||
'<decisions>',
|
||||
'### Implementation',
|
||||
'- **D-01:** use JWT tokens',
|
||||
'- **D-02** ratio 3:1',
|
||||
'</decisions>',
|
||||
].join('\n'));
|
||||
// D-02 bullet has no colon and no em-dash → parse-miss → outcome:'could-not-parse'
|
||||
// but D-01 is in decisions with trackable:true
|
||||
|
||||
// Plan covers D-01 explicitly via ## Must Haves (DESIGNATED_HEADINGS_RE match)
|
||||
writePlanFile(phaseDir, '01', [
|
||||
'# Plan',
|
||||
'',
|
||||
'## Must Haves',
|
||||
'',
|
||||
'- D-01: implement JWT token issuance and validation',
|
||||
].join('\n'));
|
||||
|
||||
// Pre-check: confirm extractDecisions outcome so we know what the gate is receiving
|
||||
const extraction = extractDecisions([
|
||||
'<decisions>',
|
||||
'### Implementation',
|
||||
'- **D-01:** use JWT tokens',
|
||||
'- **D-02** ratio 3:1',
|
||||
'</decisions>',
|
||||
].join('\n'));
|
||||
assert.strictEqual(extraction.outcome, 'could-not-parse',
|
||||
`Pre-check: extractDecisions must return could-not-parse. Got: ${extraction.outcome}`);
|
||||
assert.ok(extraction.decisions.some(d => d.id === 'D-01'),
|
||||
`Pre-check: D-01 must be in decisions (coverage would pass for D-01 alone). Got: ${JSON.stringify(extraction.decisions)}`);
|
||||
assert.strictEqual(extraction.decisions.filter(d => d.trackable).length, 1,
|
||||
'Pre-check: exactly 1 trackable decision (D-01) — confirms decisions.length === 1 path');
|
||||
|
||||
// Gate call: with the old guard `decisions.length === 0 && outcome === 'could-not-parse'`
|
||||
// this would be skipped (length is 1) and coverage would find D-01 covered → passed:true.
|
||||
// With the fix this must return passed:false.
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runDecisionCoveragePlan(phaseDir, contextPath, tmpDir);
|
||||
const parsed = JSON.parse(result.output || '');
|
||||
assert.strictEqual(parsed.passed, false,
|
||||
`Gate must return passed:false when parse-miss present, even if covered decisions exist. Got: ${JSON.stringify(parsed)}`);
|
||||
assert.strictEqual(parsed.reason, 'could-not-parse',
|
||||
`Gate must report reason:'could-not-parse'. Got: ${JSON.stringify(parsed)}`);
|
||||
// Message must indicate a format/parse problem (not a coverage gap on D-01)
|
||||
const msg = (parsed.message || '').toLowerCase();
|
||||
assert.ok(
|
||||
msg.includes('could not') || msg.includes('format') || msg.includes('mismatch') || msg.includes('parse'),
|
||||
`Message must indicate parse/format issue, not D-01 coverage gap. Got: "${parsed.message}"`
|
||||
);
|
||||
// Confirm D-01 is NOT in uncovered[] — the failure is parse-miss, not a coverage gap
|
||||
assert.deepStrictEqual(parsed.uncovered, [],
|
||||
`uncovered must be empty (D-01 is covered; failure is parse-miss). Got: ${JSON.stringify(parsed.uncovered)}`);
|
||||
});
|
||||
|
||||
test('verify-side: valid D-01 covered + malformed D-02 → verify advisory surfaces could-not-parse', () => {
|
||||
// Same scenario but via decision-coverage-verify (non-blocking advisory)
|
||||
writeContextFile(phaseDir, [
|
||||
'# Phase 1 Context',
|
||||
'',
|
||||
'<decisions>',
|
||||
'- **D-01:** use JWT tokens',
|
||||
'- **D-02** ratio 3:1',
|
||||
'</decisions>',
|
||||
].join('\n'));
|
||||
writePlanFile(phaseDir, '01', '# Plan\n\n## Must Haves\n\n- D-01: implement JWT\n');
|
||||
|
||||
const contextPath = path.join(phaseDir, 'CONTEXT.md');
|
||||
const result = runGsdTools(
|
||||
['query', 'check.decision-coverage-verify', phaseDir, contextPath],
|
||||
tmpDir
|
||||
);
|
||||
const parsed = JSON.parse(result.output || '');
|
||||
assert.strictEqual(parsed.reason, 'could-not-parse',
|
||||
`Verify must surface could-not-parse reason. Got: ${JSON.stringify(parsed)}`);
|
||||
assert.strictEqual(parsed.blocking, false,
|
||||
`Verify is always non-blocking. Got: ${JSON.stringify(parsed)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX C regressions: curly-quote Claude's Discretion ───────────────────────
|
||||
|
||||
describe('FIX C: curly-quote Claude’s Discretion → trackable:false (#1372)', () => {
|
||||
test('### Claude’s Discretion (U+2019 curly apostrophe) sets trackable:false', () => {
|
||||
// REGRESSION: curly apostrophe was not stripped from category, so
|
||||
// "claudes discretion" key was not in DISCRETION_HEADINGS → trackable:true
|
||||
const curlySingle = '’';
|
||||
const md = '<decisions>\n### Claude' + curlySingle + 's Discretion\n- **D-01:** internal decision\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds.length, 1, 'one decision must be parsed');
|
||||
assert.strictEqual(ds[0].trackable, false,
|
||||
`Curly-apostrophe discretion heading must yield trackable:false. Got trackable:${ds[0].trackable}`);
|
||||
});
|
||||
|
||||
test('### Claude‘s Discretion (U+2018 opening quote) sets trackable:false', () => {
|
||||
const openSingle = '‘';
|
||||
const md = '<decisions>\n### Claude' + openSingle + 's Discretion\n- **D-01:** internal decision\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds.length, 1);
|
||||
assert.strictEqual(ds[0].trackable, false,
|
||||
`Open-single-quote discretion heading must yield trackable:false. Got trackable:${ds[0].trackable}`);
|
||||
});
|
||||
|
||||
test('[folded] tag sets trackable:false (coverage gap fix)', () => {
|
||||
// Previously NON_TRACKABLE_TAGS included 'folded' but had no dedicated test
|
||||
const md = '<decisions>\n- **D-01 [folded]:** folded decision\n</decisions>';
|
||||
const ds = parseDecisions(md);
|
||||
assert.strictEqual(ds.length, 1);
|
||||
assert.strictEqual(ds[0].trackable, false,
|
||||
`[folded] tag must yield trackable:false. Got trackable:${ds[0].trackable}`);
|
||||
assert.ok(ds[0].tags.includes('folded'), 'tags must include "folded"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FIX D regressions: gap-checker surfaces decision parse failure independently ─
|
||||
|
||||
describe('FIX D: gap-checker surfaces decision could-not-parse even when requirements exist (#1372)', () => {
|
||||
const { runGapAnalysis } = require('../gsd-core/bin/lib/gap-checker.cjs');
|
||||
|
||||
let tmpDir;
|
||||
let planningDir;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject('gsd-1372-fixd-');
|
||||
planningDir = path.join(tmpDir, '.planning');
|
||||
phaseDir = path.join(planningDir, 'phases', '01-init');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({}));
|
||||
});
|
||||
|
||||
afterEach(() => cleanup(tmpDir));
|
||||
|
||||
test('REQUIREMENTS.md with 1 req + unparseable CONTEXT.md → gap report includes format-mismatch signal', () => {
|
||||
// REGRESSION: previously the could-not-parse signal was silently masked
|
||||
// inside `if (items.length === 0)` — when requirements existed, it never fired.
|
||||
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
|
||||
fs.writeFileSync(reqPath, '- [ ] **REQ-01** Some requirement\n');
|
||||
|
||||
const ctxMd = '<decisions>\nSome prose about decisions but no D-NN bullets.\n</decisions>\n';
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), ctxMd);
|
||||
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\nREQ-01 is covered here.\n');
|
||||
|
||||
const result = runGapAnalysis(tmpDir, phaseDir);
|
||||
assert.ok(
|
||||
result.summary.includes('format mismatch') || result.summary.includes('possible format'),
|
||||
`Summary must mention format mismatch. Got: "${result.summary}"`
|
||||
);
|
||||
assert.ok(
|
||||
result.table.includes('format mismatch') || result.table.includes('possible format'),
|
||||
`Table must include format mismatch note. Got: "${result.table}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('no REQUIREMENTS.md + unparseable CONTEXT.md → gap report includes format-mismatch signal', () => {
|
||||
// Pre-existing behavior (items.length === 0 path) must still work
|
||||
const ctxMd = '<decisions>\nSome prose about decisions but no D-NN bullets.\n</decisions>\n';
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), ctxMd);
|
||||
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\nSome plan.\n');
|
||||
|
||||
const result = runGapAnalysis(tmpDir, phaseDir);
|
||||
assert.ok(
|
||||
result.summary.includes('format mismatch') || result.summary.includes('possible format'),
|
||||
`Summary must mention format mismatch. Got: "${result.summary}"`
|
||||
);
|
||||
});
|
||||
|
||||
test('REQUIREMENTS.md with 1 req + valid CONTEXT.md → no mismatch signal (clean path)', () => {
|
||||
// Ensure the fix does not introduce false positives on valid input
|
||||
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
|
||||
fs.writeFileSync(reqPath, '- [ ] **REQ-01** Some requirement\n');
|
||||
|
||||
const ctxMd = '<decisions>\n- **D-01:** Use OAuth 2.0\n</decisions>\n';
|
||||
fs.writeFileSync(path.join(phaseDir, 'CONTEXT.md'), ctxMd);
|
||||
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan\nREQ-01 is covered. D-01 is covered.\n');
|
||||
|
||||
const result = runGapAnalysis(tmpDir, phaseDir);
|
||||
assert.ok(
|
||||
!result.summary.includes('format mismatch') && !result.summary.includes('possible format'),
|
||||
`Valid input must NOT show format mismatch. Got: "${result.summary}"`
|
||||
);
|
||||
});
|
||||
});
|
||||
1142
tests/markdown-sectionizer.test.cjs
Normal file
1142
tests/markdown-sectionizer.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,46 @@ const README_FILES = [
|
||||
'docs/README.md',
|
||||
].filter(f => fs.existsSync(path.join(ROOT, f)));
|
||||
|
||||
// Detect "ProductName (description)" parentheticals in arbitrary prose, skipping
|
||||
// version references like "Claude Code (v1.32.0)" / "Claude (1.5.0)". Returns the
|
||||
// matched substrings so callers can report them. Shared by the CHANGELOG and the
|
||||
// changeset-fragment scans so both apply identical rules.
|
||||
function findProductParentheticals(content) {
|
||||
const found = [];
|
||||
for (const product of PRODUCTS) {
|
||||
// Match "ProductName (something)" but not "ProductName (v1.2.3)" (version refs are ok)
|
||||
const pattern = new RegExp(
|
||||
product.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
||||
'\\s*\\([^)]*(?!v?\\d+\\.\\d)[^)]*\\)',
|
||||
'g'
|
||||
);
|
||||
const matches = content.match(pattern);
|
||||
if (!matches) continue;
|
||||
for (const m of matches) {
|
||||
// Skip version references like "Claude Code (v1.32.0)"
|
||||
if (/\(v?\d+\.\d+/.test(m)) continue;
|
||||
found.push(m);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('product name purity (#1777)', () => {
|
||||
// Pin the shared detector's contract so neither the CHANGELOG nor the
|
||||
// fragment scan can pass vacuously: a silently-broken helper that always
|
||||
// returned [] would otherwise go undetected whenever the scanned files
|
||||
// happen to be clean.
|
||||
test('findProductParentheticals catches a real violation and allows version refs', () => {
|
||||
assert.deepEqual(
|
||||
findProductParentheticals('see Claude Code (the Anthropic CLI) for details'),
|
||||
['Claude Code (the Anthropic CLI)'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
findProductParentheticals('upgraded to Claude Code (v1.32.0)'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('no README install-block comments contain parenthetical descriptions', () => {
|
||||
const violations = [];
|
||||
|
||||
@@ -84,24 +123,7 @@ describe('product name purity (#1777)', () => {
|
||||
if (!fs.existsSync(changelog)) return;
|
||||
|
||||
const content = fs.readFileSync(changelog, 'utf-8');
|
||||
const violations = [];
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
// Match "ProductName (something)" but not "ProductName (v1.2.3)" (version refs are ok)
|
||||
const pattern = new RegExp(
|
||||
product.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
||||
'\\s*\\([^)]*(?!v?\\d+\\.\\d)[^)]*\\)',
|
||||
'g'
|
||||
);
|
||||
const matches = content.match(pattern);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
// Skip version references like "Claude Code (v1.32.0)"
|
||||
if (/\(v?\d+\.\d+/.test(m)) continue;
|
||||
violations.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
const violations = findProductParentheticals(content);
|
||||
|
||||
assert.strictEqual(
|
||||
violations.length, 0,
|
||||
@@ -112,4 +134,34 @@ describe('product name purity (#1777)', () => {
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('live changeset fragments do not include parenthetical product descriptions', () => {
|
||||
const changesetDir = path.join(ROOT, '.changeset');
|
||||
if (!fs.existsSync(changesetDir)) return;
|
||||
|
||||
// Only LIVE fragments (.changeset/*.md) render into CHANGELOG.md at release
|
||||
// time, so an impure fragment silently re-introduces a #1777 violation at the
|
||||
// next release / back-merge even after CHANGELOG.md itself was hand-fixed.
|
||||
// Archived fragments (.changeset/archived/) never re-render and are out of scope.
|
||||
const fragments = fs.readdirSync(changesetDir, { withFileTypes: true })
|
||||
.filter(d => d.isFile() && d.name.endsWith('.md') && d.name !== 'README.md')
|
||||
.map(d => d.name);
|
||||
|
||||
const violations = [];
|
||||
for (const frag of fragments) {
|
||||
const content = fs.readFileSync(path.join(changesetDir, frag), 'utf-8');
|
||||
for (const m of findProductParentheticals(content)) {
|
||||
violations.push(frag + ' — ' + m);
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
violations.length, 0,
|
||||
[
|
||||
'Changeset fragments must not include parenthetical product descriptions',
|
||||
'(fragment prose renders verbatim into CHANGELOG.md at release time):',
|
||||
...violations.map(v => ' ' + v),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user