fix(#3707): degrade the fold for every UAT gap class, and stop line endings hiding rows from the audit and the acceptance gate (#3903)
* test(#3707): failing-first coverage for reverting the fence-shortfall fold shield Pins the post-revert contract: a phase whose only gap is a fence shortfall must degrade the fold and withhold the milestone percentages, like every other gap class. Five of the eight rows are CONTROLS that pass before the change, and they carry more weight than the failing row. The failure mode of this revert is degrading TOO MUCH: a revert that sets foldScope outside the headingsSeen > 0 branch would withhold every percentage in the project, and only the no-gap control catches that. Another control catches a revert that collapses the two scopes into one and loses the distinction between what a phase reports and what the fold folds -- uat.scope must stay TRUNCATED for every gap either way, which it already is. The row that pinned the shielded behavior is rewritten rather than deleted. Deleting a test because the behavior it asserts is being reversed leaves the reversal unguarded. * fix(#3707): degrade the fold for every UAT gap class, reverting the fence-shortfall shield Maintainer decision. The two orthogonal engines split on this during #3707 and neither filed it as blocking, so it shipped in the shape the engine that raised the objection endorsed after verifying seven fixtures. The call has now gone the other way, restoring the fail-safe direction chosen twice already on this issue. The shield exempted one gap class from the fold's teeth. It could not do that safely: shortfallBlocks is a single tally incremented at exactly one site and spans BOTH a harmless fenced documentation sample AND a genuinely fence-straddled result: blocked row. Exempting it therefore could not exempt only the harmless case -- it also published a milestone percentage over a real, unread outstanding row. SCOPE.TRUNCATED means the scan could not SEE part of the evidence, which is exactly that case. scope and foldScope now agree: every gap class degrades both. The accepted over-report documented in uat.cts is unchanged and still documented there; what changed is only that it no longer buys an exemption from the fold. The comment block above it argued FOR the shield and is rewritten, because a comment defending behavior the code no longer has is worse than no comment. shortfallBlocks leaves this function's destructure but is untouched upstream, where audit-uat still consumes it. * fix(#3707): correct the caller comment, add the changeset, and name what the order tests guard Review found a SECOND comment still documenting the removed shield -- the caller's, beside the worstScope fold, stating that foldScope differs from scope for exactly one case which must not raise phase_scope_degraded or withhold the milestone's percentages. That is now the opposite of what the code does. I rewrote the buildUatRows comment in the previous commit and asserted in its message that a comment defending behavior the code no longer has is worse than no comment, then left exactly that one standing a few hundred lines away. The change had no changeset. It is user-visible: a milestone's percentage goes from published to withheld whenever any phase has a fence-shortfall-only gap. PR gates hard-fail a user-facing code diff without one. The two scopes are now identical at every return site. They are NOT collapsed -- that would change the return shape and the caller on what is meant to be a one-condition revert, and the seam is worth keeping if the distinction is ever wanted again -- but the declaration now says plainly that they agree by decision rather than by accident, so a reader does not have to re-derive it. The two order-independence tests were renamed. foldScope is monotonic with no reset path, so file order is structurally irrelevant and those rows could never have failed for the ordering reason their names promised. They do guard something real -- a multi-file phase degrading when any one file has a shortfall-only gap -- so they now say that instead. * test(#3707): failing-first coverage for the lone-CR UAT false-clean The parser splits on newline only, and the heading tokenizer agrees with it, so a lone carriage return is not a line boundary anywhere in it. CommonMark treats a lone CR as a line ending, so such a row renders to a human reader while being invisible to BOTH sides of the parser's symmetry invariant: no item, no shortfall, no headingsSeen. A phase hiding a result: blocked row this way reports 100 percent with zero diagnostics. Found by the security review of the fold-shield revert. It is the one false-clean class that revert does not reach, and it is the same bug class this issue exists to fix -- an unreadable row reported as clean. Nine rows. The LF control is what proves this is a separator defect rather than a content defect: identical bodies, one separator apart, and only one of them hides the row. CRLF and CR-inside-a-fence controls guard the coming normalization against double-counting or tearing content that legitimately contains a carriage return. Two further manifestations turned up while writing them: a leading CR breaks column-0 anchoring of the first heading, and an all-CR document flags a shortfall it cannot attribute to any row. * fix(#3707): treat a lone carriage return as a line ending in the UAT parser A lone CR was not a line boundary anywhere in the parser -- it split on newline only, and the heading tokenizer agreed with it. CommonMark treats a lone CR as a line ending, so such a row rendered to a human reader while being invisible to BOTH sides of the parser's symmetry invariant: no item, no shortfall, no headingsSeen. A phase hiding a result: blocked row that way reported 100 percent with zero diagnostics. Line endings are now normalized once at document ingress -- CRLF and lone CR both to newline -- at the two independent entry points, rather than teaching each split site about CR. Every downstream scan, offset and span therefore reads one convention. That single-frame property is deliberate: this issue already cost a HIGH when two scans read the same document through different frames. MY OWN END-TO-END TEST WAS WRONG and is replaced rather than weakened. It asserted that a lone-CR document must withhold its percentage, which reasons from the pre-fix symptom: after the fix the row is not hidden, it is surfaced, and this module deliberately keeps visible outstanding UAT work separate from completion percentages -- only unreadable evidence degrades scope. The success of the fix is what made the assertion false. The implementing agent refused to satisfy both it and the architecture and asked instead of bending either; it was right. What replaces it is a stronger contract: a lone-CR document and its LF twin, built from one source, must produce identical audit output -- scope, percent, every unresolved row by identity, and the diagnostic set. That is what 'a line-ending convention must not change what the audit reports' actually means, and it carries a non-vacuity check so it cannot pass with both sides empty. shortfallBlocks keeps being returned, now documented as currently unconsumed. An earlier reviewer told me audit-uat still consumed it and I passed that on as an instruction; it was wrong, and it was caught by checking rather than by me. * fix(#3707): normalize at the document read boundary, not at two call sites The lone-CR fix was half-applied and both review engines caught it independently. cmdAuditUat has four document ingresses, not the two I normalized: VERIFICATION.md and deferred-items.md still handed raw text to newline-only splitters, and the frontmatter extract in the UAT loop read raw content while its parser read normalized -- one audit entry mixing the two frames the fix exists to unify. Measured: a phase written twice from one source gave total_files 2 / total_items 4 under LF and results [] / total_items 0 under lone CR, with zero diagnostics. Normalizing two call sites and declaring it done is exactly why two were missed, so this moves it to the read boundary: every document now enters through a helper that normalizes, in audit-uat, in planning-inspect's readDocument, and in the shared verification-status read. Future parsers downstream get normalized text by construction rather than because someone remembered. That last seam also fixes an under-reporting case of the same root: a lone-CR VERIFICATION.md saying status: passed was read as missing, telling the user a verify step that had completed never ran. The parity test's load-bearing assertion is now marked as such. Four of its five equality checks still pass with the bug present -- only the unresolved-row identity differs -- so trimming that one as redundant would make the row vacuous. Second changeset added: the CR fix is user-visible independently of the fold revert, and one fragment covering both would have described neither. * test(#3707): failing-first coverage for the U+2028 and duplicate-result false-cleans Two more of the same class, both found by the security review of this branch and both reproduced before writing a line. normalizeLineEndings folds only carriage returns, but a JS /m anchor also treats U+2028 and U+2029 as line terminators while split on newline does not. That is the identical asymmetry the carriage-return bug exploited, one separator over, and worse in one respect: these are not CommonMark line endings, so a reader still sees the column-0 result: blocked that the tool discards. Measured: a scalar-internal result: pass placed after U+2028 wins over the real blocked line and the row disappears with no gap raised. Separately, and independent of any separator, a block with two column-0 result: lines resolves to the first with no ambiguity signalled. Prepending result: pass to a block therefore deletes an outstanding row silently; reversing the order surfaces it. Order deciding meaning is the defect, so the pair of rows pins the contract as ambiguity-is-a-gap rather than last-one-wins, leaving the fix room to implement the gap sensibly. Four controls: an ordinary marker in the same position (proving separator not content), legitimate U+2028 inside prose that must not be torn, a single result line, and a result line inside a fence that must not count as a second occurrence. * fix(#3707): scan result lines by split, not by a multiline anchor Two more false-cleans from the security review, both closed by the same change. A JS /m anchor treats U+2028 and U+2029 as line terminators while split on newline does not. A scalar-internal result: pass placed after one of those separators therefore matched as a line start and beat the real column-0 result: blocked, and the row vanished at 100 percent with no gap. Worse than the carriage-return case in one respect: these are not CommonMark line endings, so a reader still saw the blocked row the tool discarded. Separately, the non-global match returned the leftmost hit, so a block with two column-0 result: lines silently resolved to the first. Prepending result: pass deleted an outstanding row; reversing the order surfaced it. Order deciding meaning was the defect. Both close by scanning lines produced by split rather than by anchoring a regex inside the whole document: each line is tested on its own, and a count other than exactly one is reported as a parse gap instead of resolved to either candidate. I asked for U+2028 to be folded in normalizeLineEndings and that was wrong. Folding is length-preserving, so it would have made the U+2028 fixture byte-identical to the genuine two-result-line fixture -- while one requires a confident item and the other requires an ambiguity gap. No implementation can satisfy both once the distinguishing character is erased. The agent proved that and deviated rather than forcing it, which is why normalizeLineEndings still folds only carriage returns, now with a comment saying why. * fix(#3707): bound the ambiguity scan at the next heading-shaped line The split-based result scan regressed four pre-existing #3078/#3707 guards, each off by exactly one gap. My diagnosis was wrong. I read the off-by-one as double counting -- zero-result blocks taking both the new path and the pre-existing one -- and said to change the ambiguity condition from not-equal-one to greater-than-one. The agent checked and refused: the zero path was never duplicated. The real cause is double ATTRIBUTION. A block is sliced to the next TOKENIZED heading, so when the next row is untokenized -- hidden by a straddling fence, or indented and already counted by the shortfall scan -- that row's own result: line is absorbed into the previous block. The scan then saw two result lines across what are really two rows and raised a second, redundant gap on top of the one already counted elsewhere. Had the greater-than-one change gone in, the counts would have matched while the double attribution stayed. That is the compensating-adjustment failure I had asked it to refuse, and it did. The scan is now bounded at the first following heading-shaped line, either indent class, so a genuine same-block ambiguity is untouched while spillover from a row counted elsewhere is excluded. * fix(#3707): keep the U+2028 immunity, revert the ambiguity detection The ambiguity half of this change regressed the suite twice and is coming out. Attempt one double-attributed: a block is sliced to the next TOKENIZED heading, so when the real next row is untokenized its result: line was absorbed into the previous block and raised a second gap on a row already counted elsewhere. Four guards broke. Attempt two bounded the scan at the next heading-shaped line and broke thirty. An indented ### N. inside a block scalar is legitimate scalar CONTENT, not a heading, and truncating there defeats every #3078 guard that exists to stop scalar bodies being read as rows. Telling a genuinely hidden indented row apart from indented scalar text is a classification countUnattributedIndentedRows already owns; a raw regex does not have that information. What survives is the half that is sound and was never implicated in either regression: the result scan tests each line produced by split rather than anchoring a regex with the multiline flag over the whole block. split never treats U+2028 or U+2029 as a delimiter, so those separators can no longer manufacture a line start and steal a row. Everything else returns to first-match-wins, byte-identical to origin/next. The two tests pinning ambiguity-as-a-gap are removed with it, since the contract is no longer implemented here. The defect they described is real, pre-existing and independent of any separator -- result: pass before result: blocked silently deletes an outstanding row -- and it needs its own change with a scalar-aware counter rather than being wedged into a branch already carrying three fixes. * fix(#3707): correct the shared-seam rationale and restore U+2028 trailing text The revert left a stale rationale in core-utils, justifying the decision not to fold U+2028 by claiming uat.cts must tell a fake line start apart from a real second column-0 result: declaration that gets flagged as ambiguous. Nothing flags ambiguity any more; that behavior was reverted and the same file says so a few lines away. The decision is still right, the stated reason was false. This is the third stale comment this branch has shipped and had to fix, and the worst placed of them: core-utils is a shared leaf that every future document consumer will read for guidance. Rewritten to the true reason -- the scan tests each split line individually rather than anchoring over the block, so an exotic separator cannot manufacture a line start and folding is unnecessary. Also a real behavior delta I had not noticed. Dropping the multiline flag left the pattern's trailing .*$ in place, and dot never matches U+2028, so a genuine column-0 result: blocked whose TRAILING text contained one stopped parsing entirely -- a visible parse gap rather than a false clean, so fail-safe, but a regression against origin/next that nothing pinned. The trailing portion now matches any character and a test pins it by identity against its plain-LF twin. Plus the JSDoc orphaned when normalizeLineEndings moved to core-utils, and the changeset, which described neither the separator fix nor planning-inspect surfacing lone-CR rows. * fix(#3707): harden the acceptance gate, which had both halves of the same bug uat-predicate is a SECOND, independent UAT parser, and it is the one that decides phase uat-passed. It read raw bytes and anchored a multiline regex over unsplit text -- exactly the two defects this branch closed one module away in uat.cts. The consequence is worse than the audit surface it mirrors. Measured on identical bytes: a U+2028 scalar injection made the gate return passed true while planning inspect reported the same row as blocked and outstanding. The hardened surface and the gate disagreed, and the gate was the permissive one -- so a phase could be accepted over a row the audit could see and the gate could not. Both raw reads now go through the shared normalize seam and both scans test lines produced by split rather than anchoring over the document. First-match-wins, matching uat.cts; no ambiguity counting is reintroduced. Tests assert the AGREEMENT between the two surfaces rather than each separately, because divergence is the defect. Also finishes the same root cause one module over: phase complete's advisory pre-scan read raw bytes, so a lone-CR VERIFICATION.md lost its human_needed or gaps_found warning -- the fix verification.cts already got on this branch. And narrows the core-utils rationale I reworded last commit, which claimed consumers already avoid multiline anchors. uat.cts still has five over unsplit text. That is the fourth comment on this branch to assert something the code does not do, so it now states only what is true of core-utils itself. * fix(#3707): give structure and attribution different line frames, normalize the close audit Two more from review, and the first was a regression I introduced one commit earlier. Converting the gate's heading scan to split-then-match removed a detection origin/next had: a ### N. heading delimited by U+2028 was found by the old multiline scan and was not found after. So hardening the result scan quietly weakened the heading scan, and the gate stopped blocking on rows origin/next blocked -- the permissive direction, on the surface that decides acceptance. The insight I had missed is that the two scans need DIFFERENT frames. Heading detection is structure: there is no distinction to preserve, so it splits on newline or either exotic separator and finds a heading however it is delimited. The result scan is attribution: the newline-only frame is exactly what stops a scalar-internal result: from being read as a column-0 line, so it stays. One frame applied uniformly was the error. Second, a THIRD unnormalized parser family: the milestone-close audit read every artifact raw. A lone-CR VERIFICATION.md degraded to status unknown and was skipped, and deferred entries vanished outright -- measured as three items requiring decisions under LF and one under CR, on identical bytes. All nine scanner reads now normalize; six of them had the identical defect beyond the three review named. The acknowledge path stays deliberately raw, since it splices by byte offset, and now says so. Also pins the cross-newline result: divergence, and replaces three raw U+2028 literals in test source with escapes. A raw separator in a fixture is one formatter away from becoming an ordinary-character control that still passes -- vacuous in the only test pinning the separator fix. * fix(#3707): share one frame between the acknowledge writer and the audit reader Normalizing the audit scanners left the writer and the reader on different frames. cmdAuditAcknowledge derives its stored snapshot values from raw content -- correct for the SPLICE, which rewrites by byte offset -- but scanUatGaps and scanContextQuestions now recompute those same values from normalized content. For a lone-CR artifact the two can never match, so an acknowledgement never suppresses its item and it resurfaces on every audit: acknowledge became a silent no-op. Fail-safe in direction, since the item stays visible rather than being wrongly suppressed, but it is the writer and reader disagreeing about what a line is -- the exact class this branch exists to eliminate, and the fourth instance of it here. The derive functions now read a normalized copy while the splice keeps raw bytes and raw offsets, so both sides share one frame and the byte-offset rewrite is untouched. Round trip pinned for lone-CR and LF, with an existing LF marker asserted still recognised so the change cannot silently invalidate acknowledgements already in users' files. Also tightens an assertion that pinned this branch's own heading fix with a proxy: notStrictEqual against 'passed' also passes on 'pass', which IS a passing token, so it could not have caught a regression attributing a passing result to the recovered heading. It now pins the exact token. * chore(#3707): backfill changeset pr numbers Both fragments still carried the pr: 0 placeholder, which failed changeset-lint and docs-lint on PR 3903. The review had flagged the backfill as pending and I opened the PR without doing it. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
9
.changeset/gallant-eagles-zip.md
Normal file
9
.changeset/gallant-eagles-zip.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3903
|
||||
---
|
||||
**UAT rows separated only by a lone carriage return were silently dropped from the audit-uat scan.** A `VERIFICATION.md` or `deferred-items.md` written with lone-CR line endings rendered normally to a human reader, but reported zero outstanding items to the audit, hiding real human-verification and deferred-work entries. Both file types now surface their rows exactly as their LF/CRLF equivalents do.
|
||||
|
||||
**Planning-inspect now surfaces UAT rows separated only by a lone carriage return.** The same lone-CR line-ending gap also hid rows from planning-inspect's own UAT reporting; a row that previously vanished from `uat.unresolved` now appears there too, matching its LF/CRLF equivalents.
|
||||
|
||||
**A UAT row whose `result:` line had trailing text containing a Unicode line or paragraph separator (U+2028/U+2029) is no longer dropped.** A column-0 `result:` line whose text after the token happened to contain one of these separators previously failed to parse at all, silently discarding an outstanding row; it now parses the same as its plain-line equivalent. (#3707)
|
||||
5
.changeset/merry-koalas-chatter.md
Normal file
5
.changeset/merry-koalas-chatter.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 3903
|
||||
---
|
||||
**A phase with an unreadable UAT row no longer reports an affirmative milestone completion percentage.** Previously, one specific unreadable class — UAT rows hidden inside a closed code fence — was exempted from degrading a phase's fold, so a milestone could still publish a completion percentage over work nobody could actually see. Every class of unreadable UAT content now withholds the milestone's percentages the same way. The per-phase signal is unchanged: a phase's own `uat.scope` already reported "truncated" for this case and still does. (#3707)
|
||||
133
src/audit.cts
133
src/audit.cts
@@ -18,6 +18,9 @@ import { platformReadSync } from './shell-command-projection.cjs';
|
||||
import { collectSection } from './markdown-sectionizer.cjs';
|
||||
import { splitLines } from './text-lines.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtils = require('./core-utils.cjs');
|
||||
const { normalizeLineEndings } = coreUtils;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir, quickDirFrom } = planningWorkspace;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -449,8 +452,16 @@ function scanDebugSessions(planDir: string): ScanOutcome<DebugSessionItem> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
@@ -570,10 +581,14 @@ function scanQuickTasks(planDir: string): ScanOutcome<QuickTaskItem> {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeSum);
|
||||
if (content === null) {
|
||||
// #3078-CR MEDIUM 2: same normalize-at-read-boundary fix as the other
|
||||
// scans in this module — see the comment above `scanDebugSessions`'s
|
||||
// read.
|
||||
const rawContent = platformReadSync(safeSum);
|
||||
if (rawContent === null) {
|
||||
status = 'unreadable';
|
||||
} else {
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
fm = extractFrontmatter(content, safeSum);
|
||||
status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
}
|
||||
@@ -648,8 +663,16 @@ function scanThreads(planDir: string): ScanOutcome<ThreadItem> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
const status = deriveThreadStatus(fm, content);
|
||||
@@ -723,8 +746,16 @@ function scanTodos(planDir: string): ScanOutcome<TodoItem> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
|
||||
@@ -796,8 +827,16 @@ function scanSeeds(planDir: string): ScanOutcome<SeedItem> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
const status = ((fm.status as string) || 'dormant').toLowerCase();
|
||||
@@ -970,8 +1009,16 @@ function scanUatGaps(planDir: string, cwd: string): ScanOutcome<UatGapItem> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
@@ -1045,8 +1092,16 @@ function scanVerificationGaps(planDir: string, cwd: string): ScanOutcome<Verific
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
@@ -1106,8 +1161,16 @@ function scanContextQuestions(planDir: string, cwd: string): ScanOutcome<Context
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
||||
// document at this read boundary, same seam as `src/uat.cts`'s
|
||||
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
||||
// normalization itself, and extractFrontmatter/status-derivation below
|
||||
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
||||
// in this module treats as "not open" (fail-open, the permissive
|
||||
// direction) rather than a real parse gap.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
const questions = deriveOpenQuestions(content, fm);
|
||||
@@ -1194,8 +1257,14 @@ function scanDeferredItems(planDir: string, cwd: string): ScanOutcome<DeferredIt
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
// #3078-CR MEDIUM 2: normalize at this read boundary too —
|
||||
// `parseDeferredItemsWithStatus` performs no normalization of its own
|
||||
// (unlike `src/uat.cts`'s callers, which route through
|
||||
// `readNormalizedDocument`), so a lone-CR `deferred-items.md` was read as
|
||||
// one unbroken line and every entry in it silently vanished.
|
||||
const rawContent = platformReadSync(safeFilePath);
|
||||
if (rawContent === null) continue;
|
||||
const content = normalizeLineEndings(rawContent);
|
||||
|
||||
for (const item of uat.parseDeferredItemsWithStatus(content)) {
|
||||
const rawStatus = (item.status || '').toLowerCase();
|
||||
@@ -1541,6 +1610,21 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
const planDir = planningDir(cwd);
|
||||
const markerBase = { milestone: milestone as string, at };
|
||||
|
||||
// #3078-CR MEDIUM 2: every `fs.readFileSync` in this function (below, and
|
||||
// in the flat-category branch further down) is DELIBERATELY left raw,
|
||||
// unlike `auditOpenArtifacts`'s scan reads (which now route through
|
||||
// `normalizeLineEndings`). This function splices frontmatter into the
|
||||
// EXISTING content and writes the result back via `platformWriteSync` /
|
||||
// `uat.acknowledgeDeferredItem` — both `spliceFrontmatter` and
|
||||
// `acknowledgeDeferredItem` locate and rewrite a specific byte span
|
||||
// (frontmatter block / matched deferred-item text) in the file exactly as
|
||||
// it exists on disk. Normalizing first would rewrite the file's line
|
||||
// endings as a side effect of an unrelated acknowledge operation, and a
|
||||
// splice computed against normalized text can land at the wrong offset
|
||||
// when written back over the RAW (un-normalized) original. The snapshot
|
||||
// VALUE computed below IS normalized (on a separate in-memory copy, never
|
||||
// the spliced one) so it agrees with the scanner's frame — see the comment
|
||||
// at `normalizedContent` further down.
|
||||
// ── The four phase-scoped categories: --phase --file [--archived-milestone] ──
|
||||
const PHASE_SCOPED = new Set(['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']);
|
||||
if (PHASE_SCOPED.has(category as string)) {
|
||||
@@ -1576,6 +1660,15 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
|
||||
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
const fm = extractFrontmatter(content, safeFilePath);
|
||||
// Mixed-frame fix (security review, 4th instance on this branch): the
|
||||
// splice above and below stays keyed to RAW `content` (raw byte offsets
|
||||
// must not shift), but `scanUatGaps`/`scanContextQuestions` now derive
|
||||
// their comparison values from `normalizeLineEndings`d content. Deriving
|
||||
// the snapshot here from raw `content` would make a lone-CR file's
|
||||
// stored value permanently disagree with what the scanner recomputes —
|
||||
// `audit acknowledge` would be a silent no-op for lone-CR artifacts. Feed
|
||||
// the derive functions a normalized COPY; never splice from it.
|
||||
const normalizedContent = normalizeLineEndings(content);
|
||||
let snapshotKey: string;
|
||||
let currentValue: string;
|
||||
if (category === 'uat_gaps') {
|
||||
@@ -1583,7 +1676,7 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
// pending scenarios added under the same status — snapshot the
|
||||
// composite `deriveUatGapSnapshotValue` instead (see its doc comment).
|
||||
snapshotKey = 'gap_snapshot';
|
||||
currentValue = deriveUatGapSnapshotValue(((fm.status as string) || 'unknown').toLowerCase(), content);
|
||||
currentValue = deriveUatGapSnapshotValue(((fm.status as string) || 'unknown').toLowerCase(), normalizedContent);
|
||||
} else if (category === 'verification_gaps') {
|
||||
snapshotKey = 'status';
|
||||
currentValue = ((fm.status as string) || 'unknown').toLowerCase();
|
||||
@@ -1592,7 +1685,7 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
||||
// question set, not just its count (see `deriveOpenQuestionsDigest`'s
|
||||
// doc comment).
|
||||
snapshotKey = 'questions_digest';
|
||||
currentValue = deriveOpenQuestionsDigest(deriveOpenQuestions(content, fm));
|
||||
currentValue = deriveOpenQuestionsDigest(deriveOpenQuestions(normalizedContent, fm));
|
||||
}
|
||||
fm.audit_acknowledged = { ...markerBase, [snapshotKey]: currentValue };
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
|
||||
@@ -41,6 +41,56 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import shellCommandProjection = require('./shell-command-projection.cjs');
|
||||
|
||||
// ─── Line-ending normalization ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize every line ending in `content` to a bare `\n`, ONCE — the shared
|
||||
* seam every document-READ boundary in this codebase should route through
|
||||
* (#3707-CR follow-up MAJOR).
|
||||
*
|
||||
* CommonMark treats a lone CR (no paired LF) as a line ending — such a
|
||||
* document RENDERS as separate lines to a human reader — but a parser that
|
||||
* splits/tokenizes/scans on `\n` alone treats a lone-CR-separated document as
|
||||
* ONE unbroken line, hiding every row boundary in it. `src/uat.cts` originally
|
||||
* carried this exact fix as a PRIVATE, unexported function applied inside two
|
||||
* of its own parse functions (`parseUatItemsWithStats`, `parseCurrentTest`) —
|
||||
* which is why two OTHER read sites in the same module (`cmdAuditUat`'s
|
||||
* VERIFICATION and deferred-items.md ingresses) were missed: normalizing
|
||||
* per-parser means every new parser must remember to call it. Promoted here,
|
||||
* to the shared leaf module every document consumer can reach without a new
|
||||
* dependency edge, so normalization can be applied at the READ boundary
|
||||
* instead — every current and future parser fed from a boundary that calls
|
||||
* this gets normalized text by construction.
|
||||
*
|
||||
* `/\r\n?/g` is deliberately ONE alternation, not two separate replaces: a
|
||||
* two-pass `replace(/\r\n/g,'\n').replace(/\r/g,'\n')` is equivalent here
|
||||
* because the first pass already consumes every CRLF pair before the second
|
||||
* pass ever runs, but a single regex avoids relying on pass ORDER and matches
|
||||
* greedily left-to-right in one scan, so a CRLF is always consumed as ONE
|
||||
* unit (never left as a stray trailing `\r` after the `\n` half is matched
|
||||
* first) and a lone CR — including one immediately followed by nothing, i.e.
|
||||
* at EOF, or by another lone CR — is still replaced.
|
||||
*
|
||||
* This is deliberately NOT a length-preserving transform (CRLF, two UTF-16
|
||||
* units, becomes LF, one), so any offsets a caller computes must be compared
|
||||
* only against THIS normalized string, never against the original raw text.
|
||||
*
|
||||
* U+2028 LINE SEPARATOR / U+2029 PARAGRAPH SEPARATOR (#3078-CR) are
|
||||
* DELIBERATELY NOT folded here, unlike `\r`/`\r\n`. Folding is unnecessary:
|
||||
* `String.prototype.split('\n')` never treats U+2028/U+2029 as a delimiter,
|
||||
* so an exotic separator can never manufacture a fake line start for a
|
||||
* consumer that scans lines produced by `split('\n')`, rather than anchoring
|
||||
* a multiline (`/m`) regex directly over unsplit text. Only the latter
|
||||
* pattern is vulnerable to the ECMA-262 LineTerminator set including these
|
||||
* two code points. This module performs no line-anchored matching of its
|
||||
* own; a caller that scans lines should split first and match per-line
|
||||
* rather than anchor `/m` over unsplit text — this comment makes no claim
|
||||
* about whether any particular caller currently does so.
|
||||
*/
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
// ─── Path helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -459,6 +509,7 @@ function findOrphanSummaries(planFiles: string[], summaryFiles: string[]): strin
|
||||
|
||||
export = {
|
||||
toPosixPath,
|
||||
normalizeLineEndings,
|
||||
detectSubRepos,
|
||||
extractOneLinerFromBody,
|
||||
pathExistsInternal,
|
||||
|
||||
@@ -36,7 +36,7 @@ import coreUtilsMod = require('./core-utils.cjs');
|
||||
// drift and no parity test needed to police one.
|
||||
const {
|
||||
toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId,
|
||||
findUnsummarizedPlans,
|
||||
findUnsummarizedPlans, normalizeLineEndings,
|
||||
} = coreUtilsMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
@@ -2407,7 +2407,12 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
||||
phaseFullDirBaseName,
|
||||
)) {
|
||||
const verificationFilePath = path.join(phaseFullDir, file);
|
||||
const content = fs.readFileSync(verificationFilePath, 'utf-8');
|
||||
// #3707-CR follow-up MINOR: normalize line endings at this read boundary
|
||||
// (same fix as src/verification.cts's readVerificationStatus) so a
|
||||
// lone-CR VERIFICATION.md's `---\r...\r---` frontmatter fence still
|
||||
// matches extractFrontmatter's byte-0 check instead of silently
|
||||
// dropping the human_needed/gaps_found advisory warning below.
|
||||
const content = normalizeLineEndings(fs.readFileSync(verificationFilePath, 'utf-8'));
|
||||
// #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
|
||||
// from historical metadata in the file body (e.g. `previous_status: gaps_found`).
|
||||
// A full-text regex like /status: gaps_found/ matches the substring inside
|
||||
|
||||
@@ -89,6 +89,9 @@ const { collectSection, iterateBullets } = markdownSectionizer;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import markdownTable = require('./markdown-table.cjs');
|
||||
const { parseMarkdownTable, matchTableSchema } = markdownTable;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtilsMod = require('./core-utils.cjs');
|
||||
const { normalizeLineEndings } = coreUtilsMod;
|
||||
|
||||
/**
|
||||
* The wire schema version. A consumer MUST reject any value other than this
|
||||
@@ -263,7 +266,16 @@ function readDocument(filePath: string, root: string): { text: string | null; ex
|
||||
}
|
||||
|
||||
try {
|
||||
return { text: fs.readFileSync(filePath, 'utf-8'), exists: true, readable: true };
|
||||
// #3707-CR follow-up MAJOR: normalize line endings HERE, at this module's
|
||||
// one shared document-read seam, so a lone-CR document (CommonMark line
|
||||
// ending; a document using it renders as separate lines to a human
|
||||
// reader) is normalized by construction for every current and future
|
||||
// caller of `readDocument` (`buildRequirements`, `buildUatRows`, the
|
||||
// ROADMAP.md read above) — not only the ones a parser author remembered
|
||||
// to fix individually. Mirrors `src/uat.cts`'s `readNormalizedDocument`,
|
||||
// the equivalent boundary for `cmdAuditUat`; both now delegate to the
|
||||
// same `normalizeLineEndings` in `core-utils.cts`.
|
||||
return { text: normalizeLineEndings(fs.readFileSync(filePath, 'utf-8')), exists: true, readable: true };
|
||||
} catch {
|
||||
return { text: null, exists: true, readable: false };
|
||||
}
|
||||
@@ -803,6 +815,13 @@ function buildPlanRows(phaseDir: string, diagnostics: Diagnostic[], planningRoot
|
||||
|
||||
// ─── UAT ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// `scope` and `foldScope` are, by decision, identical at every return site in
|
||||
// this function as of #3078 round-8 — they are not accidentally in sync. The
|
||||
// two-field shape is kept anyway because it lets `scope` (the row's own
|
||||
// honest answer) and `foldScope` (what the caller folds into the milestone)
|
||||
// diverge again later without a signature change, should some future gap
|
||||
// class need to be reported on the row but exempted from the fold, or vice
|
||||
// versa. If you find yourself "simplifying" this to one field, don't.
|
||||
function buildUatRows(
|
||||
phasesDir: string,
|
||||
phaseDirName: string,
|
||||
@@ -889,23 +908,18 @@ function buildUatRows(
|
||||
// fold raises `phase_scope_degraded` AND, via `phaseScope`/`makeFraction`,
|
||||
// withholds BOTH progress percentages for the WHOLE milestone.
|
||||
//
|
||||
// Those teeth cannot bite on `shortfallBlocks`: that is the subset of
|
||||
// `headingsSeen` produced by the fence-suppression shortfall scan, the ONE
|
||||
// gap class `src/uat.cts` documents as an ACCEPTED OVER-REPORT — a
|
||||
// closed-fence documentation sample written with literal digits is provably
|
||||
// indistinguishable from a genuinely fence-straddled row (fence-closedness
|
||||
// is identical in both), so the scan deliberately over-reports. A COMPLETED
|
||||
// phase is terminal: nobody reopens its UAT file, so a shortfall-only
|
||||
// over-report there would withhold the project's percentages in every
|
||||
// future audit FOREVER over a paragraph of prose. Reported-and-dismissible
|
||||
// is the right shape for it; silent is not, and neither is permanent.
|
||||
//
|
||||
// Every OTHER gap class (a block with no `result:` line, an unattributed
|
||||
// indented row, an unterminated fence) has no such false-positive story and
|
||||
// still degrades the fold, as does the unreadable-FILE case above — which
|
||||
// is what `SCOPE.TRUNCATED` means per src/planning-scope.cts: the scan
|
||||
// could not SEE part of the evidence.
|
||||
const { items: fileItems, headingsSeen, shortfallBlocks } = parseUatItemsWithStats(doc.text);
|
||||
// The two now agree: EVERY gap class degrades both, including the
|
||||
// fence-suppression shortfall. `shortfallBlocks` is not exempted here
|
||||
// because it is a single tally incremented at ONE site in the scan and
|
||||
// spans BOTH a harmless closed-fence documentation sample AND a genuinely
|
||||
// fence-straddled `result: blocked` row — exempting the tally cannot
|
||||
// exempt only the harmless case, it also publishes a milestone percentage
|
||||
// over a real unread outstanding row. `SCOPE.TRUNCATED` means the scan
|
||||
// could not SEE part of the evidence (src/planning-scope.cts), which is
|
||||
// exactly the fence-straddled case. The accepted over-report itself is
|
||||
// unchanged and still documented at src/uat.cts; what changed is only
|
||||
// that it no longer buys an exemption from the fold.
|
||||
const { items: fileItems, headingsSeen } = parseUatItemsWithStats(doc.text);
|
||||
items.push(...fileItems);
|
||||
if (headingsSeen > 0) {
|
||||
diagnostics.push({
|
||||
@@ -914,7 +928,7 @@ function buildUatRows(
|
||||
detail: `UAT document has ${headingsSeen} test block(s) with no parseable result; unresolved is not a complete answer.`,
|
||||
});
|
||||
scope = SCOPE.TRUNCATED;
|
||||
if (headingsSeen > shortfallBlocks) foldScope = SCOPE.TRUNCATED;
|
||||
foldScope = SCOPE.TRUNCATED;
|
||||
}
|
||||
}
|
||||
return { items, scope, foldScope };
|
||||
@@ -1226,13 +1240,15 @@ function buildPlanningInspect(cwd: string): Record<string, unknown> {
|
||||
const phaseId = token ? token[1] : null;
|
||||
const { goal, dependencies } = buildPhaseGoalAndDependencies(cwd, roadmapDoc, phaseId, phase.dir, diagnostics);
|
||||
|
||||
// `uat.foldScope`, NOT `uat.scope` (#3078 round-8). The two differ for
|
||||
// exactly one case: a UAT document whose ONLY parse gap is the
|
||||
// fence-suppression shortfall, `src/uat.cts`'s documented ACCEPTED
|
||||
// OVER-REPORT class. That still reports honestly on the row itself
|
||||
// (`uat.scope === "truncated"` plus the `uat_unreadable` diagnostic below),
|
||||
// but it must not raise `phase_scope_degraded` and must not withhold the
|
||||
// milestone's percentages — see `buildUatRows` for the full rationale.
|
||||
// `uat.foldScope`, NOT `uat.scope` (#3078 round-8). The two currently
|
||||
// agree at every call site — see `buildUatRows` for why the field is
|
||||
// still kept separate — so folding either one here produces the same
|
||||
// result today. `foldScope` is used because it is the field with teeth:
|
||||
// it is what `worstScope` folds into the phase's overall scope, and a
|
||||
// non-COMPLETE result here raises `phase_scope_degraded` and, via
|
||||
// `makeFraction`, withholds the milestone's percentages. A phase whose
|
||||
// UAT document could not be fully read must not contribute an
|
||||
// affirmative completion to the milestone.
|
||||
const folded = worstScope(phase.scope, plans.scope, uat.foldScope, goal.scope, dependencies.scope);
|
||||
if (folded !== SCOPE.COMPLETE) {
|
||||
diagnostics.push({
|
||||
|
||||
@@ -25,6 +25,9 @@ const { readVerificationStatus } = verification;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { scopeToPhase } = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtils = require('./core-utils.cjs');
|
||||
const { normalizeLineEndings } = coreUtils;
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -156,34 +159,64 @@ function analyzeMarkdown(raw: string): { unterminatedFence: boolean; unterminate
|
||||
function parseUatResultItems(cleanContent: string): Array<{ test: number; name: string; result: string }> {
|
||||
const items: Array<{ test: number; name: string; result: string }> = [];
|
||||
|
||||
// Find all ### N. Name headings (line-anchored)
|
||||
const headingPattern = /^###\s*(\d+)\.\s*(.+)$/gm;
|
||||
const headings: Array<{ index: number; test: number; name: string }> = [];
|
||||
let hMatch: RegExpExecArray | null;
|
||||
while ((hMatch = headingPattern.exec(cleanContent)) !== null) {
|
||||
headings.push({
|
||||
index: hMatch.index + hMatch[0].length,
|
||||
test: parseInt(hMatch[1], 10),
|
||||
name: hMatch[2].trim(),
|
||||
});
|
||||
// Find all ### N. Name headings.
|
||||
// #3078-CR MEDIUM (security review follow-up): STRUCTURE and ATTRIBUTION
|
||||
// need different split frames. This is a STRUCTURE scan — finding where a
|
||||
// heading block begins — and there is no attribution distinction to
|
||||
// preserve, so split on any of `\n`, U+2028, U+2029: a heading delimited by
|
||||
// an exotic line separator (origin/next's `/m`-anchored scan found these;
|
||||
// a naive `split('\n')`-only port silently stopped finding them, making the
|
||||
// gate MORE permissive than origin/next) is found exactly like a
|
||||
// `\n`-delimited one. Contrast the `result:` scan below, which is an
|
||||
// ATTRIBUTION scan and must NOT do this.
|
||||
const HEADING_LINE_RE = /^###\s*(\d+)\.\s*(.+)$/;
|
||||
const headings: Array<{ index: number; lineStart: number; test: number; name: string }> = [];
|
||||
{
|
||||
// All three separators are exactly one UTF-16 code unit, so the
|
||||
// `line.length + 1` offset arithmetic below stays valid regardless of
|
||||
// which separator terminated a given line.
|
||||
const lines = cleanContent.split(/[\n\u2028\u2029]/);
|
||||
let offset = 0;
|
||||
for (const line of lines) {
|
||||
const hMatch = line.match(HEADING_LINE_RE);
|
||||
if (hMatch) {
|
||||
headings.push({
|
||||
index: offset + hMatch[0].length,
|
||||
lineStart: offset,
|
||||
test: parseInt(hMatch[1], 10),
|
||||
name: hMatch[2].trim(),
|
||||
});
|
||||
}
|
||||
offset += line.length + 1; // +1 for the separator consumed by split
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const h = headings[i];
|
||||
const blockStart = h.index;
|
||||
// More precise: find next heading's position in the original string
|
||||
// We'll slice from current heading end to the position just before next heading's "###"
|
||||
const nextHeadingMatch = i + 1 < headings.length
|
||||
? cleanContent.lastIndexOf('\n###', headings[i + 1].index)
|
||||
: -1;
|
||||
const blockContent = nextHeadingMatch >= blockStart
|
||||
? cleanContent.slice(blockStart, nextHeadingMatch)
|
||||
// A block spans until the START of the next heading's line (tracked
|
||||
// directly from the same split-frame scan above), not a re-search for a
|
||||
// literal '\n###' over unsplit text -- the latter would silently miss a
|
||||
// next heading delimited by U+2028/U+2029 instead of '\n' and swallow
|
||||
// every subsequent block into this one.
|
||||
const blockContent = i + 1 < headings.length
|
||||
? cleanContent.slice(blockStart, headings[i + 1].lineStart)
|
||||
: cleanContent.slice(blockStart);
|
||||
|
||||
// Column-0 anchored result line: /^result:[ \t]*\[?([\w-]+)\]?/mi
|
||||
// Column-0 result line, split-then-match (#3078-CR MEDIUM — same fix as
|
||||
// the heading scan above): test each already-split line individually
|
||||
// against a single-line (no `/m` anchor) pattern instead of anchoring
|
||||
// over unsplit `blockContent`, so a `result:`-shaped line reachable only
|
||||
// via a U+2028/U+2029 separator inside an `expected: |` scalar body can
|
||||
// never register as a fake column-0 match. FIRST MATCH WINS — no
|
||||
// ambiguity counting, matching src/uat.cts's contract.
|
||||
// Uses [ \t]* (not \s*) so the captured value must sit on the SAME line as result:.
|
||||
// A result: key with the value on a subsequent line yields no match → 'missing' (blocker).
|
||||
const resultMatch = /^result:[ \t]*\[?([\w-]+)\]?/mi.exec(blockContent);
|
||||
const RESULT_LINE_RE = /^result:[ \t]*\[?([\w-]+)\]?/i;
|
||||
const resultMatch = blockContent
|
||||
.split('\n')
|
||||
.map((line) => line.match(RESULT_LINE_RE))
|
||||
.find((m): m is RegExpMatchArray => m !== null) ?? null;
|
||||
if (resultMatch) {
|
||||
items.push({
|
||||
test: h.test,
|
||||
@@ -265,7 +298,11 @@ function evaluateUatPassed(
|
||||
const uatFilePath = path.join(phaseFullDir, file);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(uatFilePath, 'utf-8');
|
||||
// #3078-CR MEDIUM: normalize line endings at the read boundary — the
|
||||
// same seam src/uat.cts and src/verification.cts route through — so a
|
||||
// lone-CR *-UAT.md is not read as one unbroken line by the column-0
|
||||
// scans below.
|
||||
raw = normalizeLineEndings(fs.readFileSync(uatFilePath, 'utf-8'));
|
||||
} catch {
|
||||
blockers.push(`${file}: could not read file`);
|
||||
continue;
|
||||
@@ -317,7 +354,8 @@ function evaluateUatPassed(
|
||||
const verificationFilePath = path.join(phaseFullDir, file);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(verificationFilePath, 'utf-8');
|
||||
// #3078-CR MEDIUM: same read-boundary normalization as the UAT loop above.
|
||||
raw = normalizeLineEndings(fs.readFileSync(verificationFilePath, 'utf-8'));
|
||||
} catch {
|
||||
blockers.push(`${file}: could not read verification file`);
|
||||
continue;
|
||||
|
||||
89
src/uat.cts
89
src/uat.cts
@@ -22,7 +22,7 @@ import markdownTable = require('./markdown-table.cjs');
|
||||
const { splitTableRow, isDelimiterRow } = markdownTable;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import coreUtils = require('./core-utils.cjs');
|
||||
const { toPosixPath } = coreUtils;
|
||||
const { toPosixPath, normalizeLineEndings } = coreUtils;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
@@ -114,6 +114,25 @@ function selectPhaseUatFiles(files: string[], phaseDirName: string): string[] {
|
||||
return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE read boundary for every document `cmdAuditUat` scans off disk
|
||||
* (#3707-CR follow-up MAJOR). Wraps `fs.readFileSync` +
|
||||
* `normalizeLineEndings` in a single seam so a lone-CR-separated
|
||||
* `*-UAT.md`, `*-VERIFICATION.md`, or `deferred-items.md` is normalized BY
|
||||
* CONSTRUCTION before it reaches ANY downstream parser — current
|
||||
* (`parseUatItemsWithStats`, `parseVerificationItems`, `parseDeferredItems`)
|
||||
* or future. Fixing this per-parser was the original (#3707-CR) MEDIUM fix's
|
||||
* mistake: two of the four ingresses in this function were normalized by
|
||||
* editing their own parsers directly, and the other two (VERIFICATION,
|
||||
* deferred-items.md) were missed precisely because nothing forced a new call
|
||||
* site to remember the step. Routing every read through this function
|
||||
* removes that failure mode: a parser added later needs no line-ending logic
|
||||
* of its own, because the text it receives is already normalized.
|
||||
*/
|
||||
function readNormalizedDocument(filePath: string): string {
|
||||
return normalizeLineEndings(fs.readFileSync(filePath, 'utf-8'));
|
||||
}
|
||||
|
||||
function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const hasActivePhases = fs.existsSync(phasesDir);
|
||||
@@ -174,7 +193,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
// the reason scopeToPhase has no unfiltered fallback.
|
||||
for (const file of selectPhaseUatFiles(files, dir)) {
|
||||
const uatFilePath = path.join(phaseDir, file);
|
||||
const content = fs.readFileSync(uatFilePath, 'utf-8');
|
||||
const content = readNormalizedDocument(uatFilePath);
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||||
const status = (extractFrontmatter(content, uatFilePath).status as string || 'unknown');
|
||||
// `parse_gap` means the file contained `### N.` test blocks that
|
||||
@@ -235,7 +254,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
// for the same reason as the UAT loop above.
|
||||
for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
|
||||
const verificationFilePath = path.join(phaseDir, file);
|
||||
const content = fs.readFileSync(verificationFilePath, 'utf-8');
|
||||
const content = readNormalizedDocument(verificationFilePath);
|
||||
const status = extractFrontmatter(content, verificationFilePath).status as string || 'unknown';
|
||||
if (status === 'human_needed' || status === 'gaps_found') {
|
||||
const items = parseVerificationItems(content, status, verificationFilePath);
|
||||
@@ -263,7 +282,7 @@ function cmdAuditUat(cwd: string, raw: boolean): void {
|
||||
// required.
|
||||
const deferredFile = 'deferred-items.md';
|
||||
if (files.includes(deferredFile)) {
|
||||
const content = fs.readFileSync(path.join(phaseDir, deferredFile), 'utf-8');
|
||||
const content = readNormalizedDocument(path.join(phaseDir, deferredFile));
|
||||
const items = parseDeferredItems(content);
|
||||
if (items.length > 0) {
|
||||
results.push({
|
||||
@@ -364,6 +383,13 @@ function cmdRenderCheckpoint(cwd: string, options: { file?: string } = {}, raw:
|
||||
// ─── parseCurrentTest ─────────────────────────────────────────────────────────
|
||||
|
||||
function parseCurrentTest(content: string): CurrentTest {
|
||||
// #3707-CR: this is the render-checkpoint path's own independent ingress
|
||||
// into `tokenizeHeadings` (via the `parseFirstPendingTest` fallback below),
|
||||
// separate from `parseUatItemsWithStats`'s. Normalize here too, ONCE, so a
|
||||
// lone-CR document cannot hide its first pending row from this path either
|
||||
// — see `normalizeLineEndings` for why.
|
||||
content = normalizeLineEndings(content);
|
||||
|
||||
// Use the seam to locate the ## Current Test section (ADR-1372 T5).
|
||||
// HTML-comment stripping within the section body is UAT-specific, so we keep
|
||||
// the comment removal caller-side after extracting the body.
|
||||
@@ -1220,10 +1246,21 @@ function countUnattributedIndentedRows(surface: string): number {
|
||||
* Reported separately so a consumer that must decide whether to WITHHOLD a
|
||||
* derived number — as opposed to merely REPORT the gap — can tell "a row I
|
||||
* definitely could not read" from "a row I possibly mis-counted".
|
||||
* `src/planning-inspect.cts`'s `buildUatRows` is that consumer; `cmdAuditUat`
|
||||
* is not, and still gates `parse_gap` on the total.
|
||||
*
|
||||
* #3707-CR: `src/planning-inspect.cts`'s `buildUatRows` does NOT destructure
|
||||
* this field (verified — it and `cmdAuditUat` both consume only `items` and
|
||||
* `headingsSeen`), correcting an earlier stated instruction that it did.
|
||||
* `shortfallBlocks` currently has NO production consumer outside this
|
||||
* function's own computation. It is retained on the return value anyway,
|
||||
* deliberately, as part of this function's published stats contract — tests
|
||||
* assert on the full `{ items, headingsSeen, shortfallBlocks }` shape, and
|
||||
* dropping a returned field is a wider, unrelated change than a line-ending
|
||||
* fix warrants. A future consumer that needs to distinguish an
|
||||
* accepted-over-report shortfall from the rest of `headingsSeen` (the
|
||||
* original design intent above) can still do so.
|
||||
*/
|
||||
function parseUatItemsWithStats(content: string): { items: UatItem[]; headingsSeen: number; shortfallBlocks: number } {
|
||||
content = normalizeLineEndings(content);
|
||||
const items: UatItem[] = [];
|
||||
let headingsSeen = 0;
|
||||
let shortfallBlocks = 0;
|
||||
@@ -1422,7 +1459,45 @@ function parseUatItemsWithStats(content: string): { items: UatItem[]; headingsSe
|
||||
// doing so previously changed `categorizeItem`'s classification for
|
||||
// shapes origin/next categorized differently (an unpinned behavior
|
||||
// change, not something the blocker required).
|
||||
const resultLineMatch = fenceStrippedBlock.match(/^result:\s*\[?(\w+)\]?.*$/im);
|
||||
// #3078-CR defect A fix, split-then-match scan: the previous `.match()`
|
||||
// against `/^result:.../im` ran a MULTILINE regex anchor directly over
|
||||
// unsplit block text. ECMA-262's LineTerminator set for `^`/`$` under
|
||||
// `/m` includes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, but
|
||||
// `content.split('\n')` and this module's own heading tokenizer do NOT
|
||||
// treat either as a boundary. A `result:`-shaped line inside an
|
||||
// `expected: |` scalar body, sitting immediately after one of these
|
||||
// separators instead of an ordinary character, was therefore read as a
|
||||
// genuine line start by the regex engine even though it is not
|
||||
// `\n`-delimited from anything — it is exactly as much "one line" to
|
||||
// every other consumer as the ordinary-character control case.
|
||||
// Splitting on `\n` FIRST and testing each already-split line against a
|
||||
// single-line (`/im`-anchor-free) pattern fixes this: a line is never
|
||||
// split by U+2028/U+2029 (`String.prototype.split` matches only its
|
||||
// literal separator argument, never the wider ECMA-262 LineTerminator
|
||||
// set), so a `result:`-shaped line reachable only via one of those
|
||||
// separators can never register as its own split line — the split view
|
||||
// and the regex view are back in agreement, by construction, exactly the
|
||||
// way `splitLines` module is documented to be immune to the sibling `\r`
|
||||
// bug.
|
||||
//
|
||||
// FIRST MATCH WINS (byte-identical to origin/next otherwise): a block
|
||||
// with more than one column-0 `result:` line resolves to the FIRST one
|
||||
// encountered, same as the pre-existing `.match()` behaviour without
|
||||
// `/g` — this is deliberately NOT an ambiguity/parse-gap case (that
|
||||
// variant was tried and reverted: its boundary-truncation heuristic
|
||||
// mistook an indented `### N.` living inside a legitimate block scalar
|
||||
// for a heading boundary, corrupting every scalar/indent guard in this
|
||||
// module — see tests/uat.test.cjs's #3078 scalar guard family).
|
||||
// Trailing text is matched with `[^]*` rather than `.*` (final review
|
||||
// MINOR 1): `.` never matches U+2028/U+2029, so a column-0 `result:`
|
||||
// line whose trailing text contains one of those separators would
|
||||
// otherwise never reach `$`, and the whole line would fail to match —
|
||||
// an unpinned regression against origin/next, which parses it.
|
||||
const RESULT_LINE_RE = /^result:\s*\[?(\w+)\]?[^]*$/i;
|
||||
const resultLineMatch = fenceStrippedBlock
|
||||
.split('\n')
|
||||
.map((line) => line.match(RESULT_LINE_RE))
|
||||
.find((m): m is RegExpMatchArray => m !== null);
|
||||
if (!resultLineMatch) {
|
||||
headingsSeen += 1;
|
||||
continue;
|
||||
|
||||
@@ -37,6 +37,8 @@ import phaseId = require('./phase-id.cjs');
|
||||
import frontmatterMod = require('./frontmatter.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
|
||||
import scanPhasePlans = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core-utils.cjs is an export= CommonJS module
|
||||
import coreUtilsMod = require('./core-utils.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
|
||||
import planningScopeMod = require('./planning-scope.cjs');
|
||||
import { execGit } from './shell-command-projection.cjs';
|
||||
@@ -45,6 +47,7 @@ import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
const { output, error } = io;
|
||||
const { extractPhaseToken, scopeToPhase } = phaseId;
|
||||
const { extractFrontmatter } = frontmatterMod;
|
||||
const { normalizeLineEndings } = coreUtilsMod;
|
||||
const { SCOPE } = planningScopeMod;
|
||||
type Scope = planningScopeMod.Scope;
|
||||
|
||||
@@ -650,7 +653,17 @@ function readVerificationStatus(
|
||||
const filePath = path.join(phaseDir, verificationFile);
|
||||
let rawStatus: string | null = null;
|
||||
try {
|
||||
const content = fsImpl.readFileSync(filePath, 'utf-8');
|
||||
// #3707-CR follow-up MINOR 1: normalize line endings at this read
|
||||
// boundary — this function's own `readFileSync` is the equivalent seam
|
||||
// `planning.inspect`'s `buildUatRows`/`readDocument` route through for
|
||||
// UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
|
||||
// normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
|
||||
// frontmatter fence never matched `extractFrontmatter`'s byte-0
|
||||
// `---\n`/`---\r\n` check, so `status: passed` was read as absent and
|
||||
// this function reported 'missing' — under-reporting a completed
|
||||
// verification as if the step never ran, the fail-safe direction but the
|
||||
// same root cause as the false-clean class fixed elsewhere in #3707-CR.
|
||||
const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
|
||||
const fm = extractFrontmatter(content, filePath);
|
||||
const statusVal = fm['status'];
|
||||
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
||||
|
||||
@@ -2409,5 +2409,95 @@ describe('bug #950: quick-task SUMMARY must carry status: complete', () => {
|
||||
const result = ack(tmpDir, ['--category', 'uat_gaps', '--milestone', 'v1.0']); // no --phase/--file
|
||||
assert.equal(result.success, false, 'missing --phase/--file must be refused');
|
||||
});
|
||||
|
||||
// ── mixed-frame fix (security review, #3078-CR follow-up): the writer's
|
||||
// snapshot value and the scanners' recomputed value must share ONE
|
||||
// frame (both normalized) even though the writer's SPLICE stays raw. A
|
||||
// lone-CR artifact discriminates this: normalizing shifts every byte
|
||||
// offset, so if the splice used normalized text it would corrupt the
|
||||
// file, and if the snapshot used raw text it would never match the
|
||||
// scanner's normalized recomputation — acknowledge would silently never
|
||||
// suppress. An LF control proves the round trip isn't accidentally
|
||||
// broken for the common case while fixing the CR case. ──────────────
|
||||
|
||||
test('mixed-frame fix: context_questions acknowledge on a lone-CR artifact actually suppresses the item', () => {
|
||||
// A lone-CR CONTEXT.md is the clean discriminator: `deriveOpenQuestions`
|
||||
// splits the `## Open Questions` body on `\n` — raw lone-CR content has
|
||||
// NO `\n` at all, so pre-fix the writer's raw-content digest is
|
||||
// computed over a ZERO-question set (sha256 of ''), which can never
|
||||
// equal what the scanner (reading normalized content) recomputes — the
|
||||
// acknowledge is a silent no-op. This file carries no frontmatter
|
||||
// fence, so it is not entangled with the separate, pre-existing
|
||||
// splice-vs-lone-CR-fence limitation a UAT/VERIFICATION file with an
|
||||
// EXISTING lone-CR frontmatter block would hit.
|
||||
const phaseDir = planningPath('phases', '01-alpha');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const filePath = path.join(phaseDir, '01-CONTEXT.md');
|
||||
fs.writeFileSync(filePath, '# Context\r\r## Open Questions\r\r- Which backend?\r- What about auth?\r');
|
||||
|
||||
const before = audit(tmpDir);
|
||||
assert.equal(before.counts.context_questions, 1, 'lone-CR CONTEXT file must be parsed as having open questions before acknowledge');
|
||||
|
||||
const result = ack(tmpDir, ['--category', 'context_questions', '--phase', '01', '--file', '01-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
|
||||
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
|
||||
assert.notEqual(
|
||||
JSON.parse(result.output).questions_digest,
|
||||
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||||
'the recorded digest must reflect the REAL 2-question set, not sha256 of an empty set (the pre-fix raw-content bug)',
|
||||
);
|
||||
|
||||
const after = audit(tmpDir);
|
||||
assert.equal(after.counts.context_questions, 0, 'lone-CR context_questions item must be SUPPRESSED, not resurface as a silent no-op');
|
||||
assert.equal(after.acknowledged.context_questions, 1);
|
||||
assert.deepEqual(
|
||||
(after.items.context_questions || []).filter((i) => !i.scan_error).map((i) => i.file),
|
||||
[],
|
||||
'the specific acknowledged file must be gone from the open items, by identity — not just a smaller count',
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed-frame fix control: context_questions acknowledge on an LF artifact still suppresses the item (round trip not broken by the CR fix)', () => {
|
||||
const phaseDir = planningPath('phases', '02-beta');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const filePath = path.join(phaseDir, '02-CONTEXT.md');
|
||||
fs.writeFileSync(filePath, '# Context\n\n## Open Questions\n\n- Which backend?\n- What about auth?\n');
|
||||
|
||||
const before = audit(tmpDir);
|
||||
assert.equal(before.counts.context_questions, 1);
|
||||
|
||||
const result = ack(tmpDir, ['--category', 'context_questions', '--phase', '02', '--file', '02-CONTEXT.md', '--milestone', 'v1.0', '--at', '2026-08-15']);
|
||||
assert.ok(result.success, `acknowledge must succeed. stderr: ${result.error}`);
|
||||
|
||||
const after = audit(tmpDir);
|
||||
assert.equal(after.counts.context_questions, 0, 'LF context_questions item must still be suppressed after the fix');
|
||||
assert.equal(after.acknowledged.context_questions, 1);
|
||||
assert.deepEqual(
|
||||
(after.items.context_questions || []).filter((i) => !i.scan_error).map((i) => i.file),
|
||||
[],
|
||||
'the specific acknowledged file must be gone from the open items, by identity',
|
||||
);
|
||||
assert.ok(!fs.readFileSync(filePath, 'utf-8').includes('\r'), 'LF file stays LF — the fix must not introduce CR bytes on the common path');
|
||||
});
|
||||
|
||||
test('mixed-frame fix compatibility: a HAND-AUTHORED pre-existing LF acknowledgment marker (never touched by this change\'s writer) is still recognised', () => {
|
||||
const phaseDir = planningPath('phases', '03-gamma');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
const filePath = path.join(phaseDir, '03-UAT.md');
|
||||
// The marker's `gap_snapshot` value is computed BY HAND here, per
|
||||
// `deriveUatGapSnapshotValue`'s documented shape
|
||||
// (`${status}::scenarios=${openScenarioCount}`) — NOT produced by
|
||||
// calling the writer — so this exercises the scanner's READ side in
|
||||
// isolation against a marker that predates this change entirely. This
|
||||
// content has zero `result: pending`/`[pending]` matches, so the open
|
||||
// scenario count is 0.
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
'---\nstatus: gaps_found\naudit_acknowledged:\n milestone: v1.0\n at: "2026-08-15"\n gap_snapshot: "gaps_found::scenarios=0"\n---\n# UAT\n\n## Gaps\n\n- truth: "something broke"\n status: open\n',
|
||||
);
|
||||
|
||||
const after = audit(tmpDir);
|
||||
assert.equal(after.counts.uat_gaps, 0, 'a pre-existing LF acknowledgment marker must still suppress the item after the mixed-frame fix');
|
||||
assert.equal(after.acknowledged.uat_gaps, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1226,26 +1226,73 @@ describe('planning inspect — evidence kept separate, never folded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #3078 round-8: a shortfall is REPORTED, it does not WITHHOLD ──────────
|
||||
// ─── #3078 round-8 REVERTED (fix/3707-fold-shield-revert): the shield is
|
||||
// GONE — a fence-suppression shortfall now degrades the fold exactly like
|
||||
// every other UAT parse gap. `phase.uat.scope` (this phase's own reported
|
||||
// evidence quality) and `phase.scope` (the `worstScope` fold that gates
|
||||
// `phase_scope_degraded` and — via `progress.*` — the milestone's
|
||||
// percentages) remain two SEPARATE fields with two separate meanings, but
|
||||
// as of this revert they no longer disagree on a shortfall-only gap: both
|
||||
// report degraded evidence. `src/uat.cts` still counts `shortfallBlocks` as
|
||||
// its own documented ACCEPTED-OVER-REPORT subset of `headingsSeen`, but
|
||||
// `buildUatRows` no longer reads that subset when deciding `foldScope` — see
|
||||
// `src/planning-inspect.cts`'s `buildUatRows` for the current (unconditional)
|
||||
// rule: `headingsSeen > 0` alone sets BOTH `scope` and `foldScope` to
|
||||
// TRUNCATED, with no `headingsSeen > shortfallBlocks` comparison left to
|
||||
// exempt anything.
|
||||
//
|
||||
// `phase.uat.scope` (what this phase's UAT evidence is worth) and
|
||||
// `phase.scope` (the `worstScope` fold, which gates `phase_scope_degraded`
|
||||
// and — via `progress.*` — the milestone's percentages) are separate
|
||||
// decisions. The fence-suppression shortfall is `src/uat.cts`'s documented
|
||||
// ACCEPTED OVER-REPORT class: a closed-fence documentation sample with
|
||||
// literal digits is indistinguishable from a straddled row. A COMPLETED
|
||||
// phase is terminal, so letting that class withhold percentages would make a
|
||||
// paragraph of prose suppress the project's numbers in every future audit
|
||||
// forever.
|
||||
// Boundary matrix (`headingsSeen`, `shortfallBlocks`):
|
||||
// (0, 0) -> no diagnostic, fold COMPLETE — unchanged
|
||||
// (1, 1) -> fold TRUNCATED — THE CHANGE (below)
|
||||
// (1, 0) -> fold TRUNCATED — unchanged
|
||||
// (2, 1) -> fold TRUNCATED — unchanged
|
||||
|
||||
test('shortfallAloneReportsTheGapWithoutDegradingThePhaseOrWithholdingThePercentage', (t) => {
|
||||
test('shortfallAloneDegradesTheFoldAndWithholdsThePercentage', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
writeVerification(phaseDir, '1', 'passed');
|
||||
// A `## Notes` section documenting the row format inside a CLOSED fence —
|
||||
// literal digits, so `TEST_HEADING_LINE_RE` counts it and the shortfall
|
||||
// fires. This is prose, not an outstanding row.
|
||||
// scan fires: headingsSeen === 1, shortfallBlocks === 1 (the boundary
|
||||
// case the removed `headingsSeen > shortfallBlocks` comparison used to
|
||||
// treat specially — post-revert there is no comparison left, so this is
|
||||
// ordinary `headingsSeen > 0`).
|
||||
writeUatDocWithStatus(phaseDir, '1', 'complete', [
|
||||
'# UAT',
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'How to write a row:',
|
||||
'',
|
||||
'```',
|
||||
'### 1. Example Row',
|
||||
'expected: x',
|
||||
'result: pass',
|
||||
'```',
|
||||
'',
|
||||
]);
|
||||
|
||||
const payload = parseInspect(tmpDir);
|
||||
const phase = payload.phases[0];
|
||||
// Post-revert: the fold now degrades on a shortfall-only gap, and the
|
||||
// milestone percentage is withheld — the exact behavior #3707's shield
|
||||
// used to suppress.
|
||||
assert.strictEqual(phase.scope, 'truncated');
|
||||
assert.ok(payload.diagnostics.some((d) => d.code === 'phase_scope_degraded' && d.subject === phase.dir));
|
||||
assert.strictEqual(payload.progress.accepted_phases.percent, null);
|
||||
assert.ok(payload.diagnostics.some((d) => d.code === 'percent_withheld'));
|
||||
});
|
||||
|
||||
test('shortfallAloneStillReportsTruncatedUatScopeAndTheUnreadableDiagnostic', (t) => {
|
||||
// CONTROL: the per-phase `uat.scope` reporting and the `uat_unreadable`
|
||||
// diagnostic are untouched by the shield revert — they already went
|
||||
// TRUNCATED for every gap, shortfall included. Only the FOLD (asserted in
|
||||
// the row above) changed.
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
writeVerification(phaseDir, '1', 'passed');
|
||||
writeUatDocWithStatus(phaseDir, '1', 'complete', [
|
||||
'# UAT',
|
||||
'',
|
||||
@@ -1263,14 +1310,8 @@ describe('planning inspect — evidence kept separate, never folded', () => {
|
||||
|
||||
const payload = parseInspect(tmpDir);
|
||||
const phase = payload.phases[0];
|
||||
// REPORTED …
|
||||
assert.ok(payload.diagnostics.some((d) => d.code === 'uat_unreadable' && d.subject.includes('1-UAT.md')));
|
||||
assert.strictEqual(phase.uat.scope, 'truncated');
|
||||
// … but NOT degraded, and the numbers still publish.
|
||||
assert.strictEqual(phase.scope, 'complete');
|
||||
assert.strictEqual(payload.diagnostics.filter((d) => d.code === 'phase_scope_degraded').length, 0);
|
||||
assert.strictEqual(payload.diagnostics.filter((d) => d.code === 'percent_withheld').length, 0);
|
||||
assert.strictEqual(payload.progress.accepted_phases.percent, 100);
|
||||
assert.ok(payload.diagnostics.some((d) => d.code === 'uat_unreadable' && d.subject.includes('1-UAT.md')));
|
||||
});
|
||||
|
||||
test('aNonShortfallParseGapStillDegradesThePhaseAndWithholdsThePercentage', (t) => {
|
||||
@@ -1315,6 +1356,250 @@ describe('planning inspect — evidence kept separate, never folded', () => {
|
||||
assert.strictEqual(payload.progress.accepted_phases.percent, null);
|
||||
});
|
||||
|
||||
test('aPhaseWithNoUatGapAtAllStillPublishesThePercentage', (t) => {
|
||||
// CONTROL for the catastrophic-revert failure mode: a revert that sets
|
||||
// `foldScope = SCOPE.TRUNCATED` unconditionally, OUTSIDE the
|
||||
// `headingsSeen > 0` branch, would withhold every percentage in the
|
||||
// project — including this phase, which has no gap whatsoever.
|
||||
// Boundary case: headingsSeen === 0, shortfallBlocks === 0.
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
writeVerification(phaseDir, '1', 'passed');
|
||||
writeUatDocWithStatus(phaseDir, '1', 'complete', [
|
||||
'### 1. Alpha',
|
||||
'expected: a',
|
||||
'result: pass',
|
||||
'',
|
||||
]);
|
||||
|
||||
const payload = parseInspect(tmpDir);
|
||||
const phase = payload.phases[0];
|
||||
assert.strictEqual(phase.uat.scope, 'complete');
|
||||
assert.strictEqual(phase.scope, 'complete');
|
||||
assert.strictEqual(payload.diagnostics.filter((d) => d.code === 'phase_scope_degraded').length, 0);
|
||||
assert.strictEqual(payload.diagnostics.filter((d) => d.code === 'uat_unreadable').length, 0);
|
||||
assert.strictEqual(payload.progress.accepted_phases.percent, 100);
|
||||
});
|
||||
|
||||
test('aMixedShortfallAndGenuineParseGapPhaseDegradesTheFold', (t) => {
|
||||
// CONTROL: a file carrying BOTH a shortfall block and a genuine
|
||||
// (non-shortfall) parse gap in the same document — headingsSeen === 2,
|
||||
// shortfallBlocks === 1. `headingsSeen > shortfallBlocks` was already true
|
||||
// under the OLD shielded rule (2 > 1), so this row degraded the fold
|
||||
// before the revert too; it stays green throughout.
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
writeVerification(phaseDir, '1', 'passed');
|
||||
writeUatDocWithStatus(phaseDir, '1', 'complete', [
|
||||
// A genuine gap: a column-0 `### N.` block with no `result:` line.
|
||||
'### 1. Alpha',
|
||||
'expected: a',
|
||||
'',
|
||||
// A shortfall: a closed-fence documentation sample with literal digits.
|
||||
'## Notes',
|
||||
'',
|
||||
'```',
|
||||
'### 2. Example Row',
|
||||
'expected: x',
|
||||
'result: pass',
|
||||
'```',
|
||||
'',
|
||||
]);
|
||||
|
||||
const payload = parseInspect(tmpDir);
|
||||
const phase = payload.phases[0];
|
||||
assert.strictEqual(phase.uat.scope, 'truncated');
|
||||
assert.strictEqual(phase.scope, 'truncated');
|
||||
assert.ok(payload.diagnostics.some((d) => d.code === 'phase_scope_degraded' && d.subject === phase.dir));
|
||||
assert.strictEqual(payload.progress.accepted_phases.percent, null);
|
||||
});
|
||||
|
||||
// ─── #3707-CR security review MEDIUM ────────────────────────────────────────
|
||||
//
|
||||
// A lone CR (`String.fromCharCode(13)`, no paired LF) is a CommonMark line
|
||||
// ending — a document using it renders as separate lines to a human reader.
|
||||
// `src/uat.cts` now normalizes every line ending at parse ingress
|
||||
// (`normalizeLineEndings`), so the row is no longer hidden: it is parsed and
|
||||
// surfaces as a visible outstanding `uat.unresolved` item, exactly like its
|
||||
// LF twin. See `tests/uat.test.cjs`'s "#3707-CR" describe block for the
|
||||
// parser-level pin this end-to-end case is downstream of.
|
||||
//
|
||||
// This test does NOT assert `percent === null` (an earlier version of this
|
||||
// test did, and was wrong — reasoning from the PRE-fix symptom instead of
|
||||
// the post-fix behavior). A surfaced `result: blocked` row is VISIBLE
|
||||
// outstanding work, not unreadable evidence, and this module's pinned
|
||||
// invariant is that visible outstanding UAT work deliberately does NOT
|
||||
// withhold percentages or degrade scope — only genuinely UNREADABLE
|
||||
// evidence does (`keepsUnresolvedUatAndPassingVerificationSeparateWithNoCombinedVerdict`,
|
||||
// `uatAbsenceDoesNotAffectAcceptedPhases`, "evidence kept separate, never
|
||||
// folded"). Do not "fix" this test back to `percent === null` — the
|
||||
// asymmetry it once demanded would be the bug, not the fix.
|
||||
//
|
||||
// The contract that actually matters, and is much harder to satisfy
|
||||
// accidentally, is PARITY: a line-ending convention must not change what
|
||||
// the audit reports. Both documents below are derived from ONE source
|
||||
// string, differing only in which separator carries the line break, so the
|
||||
// two fixtures cannot drift apart under later editing.
|
||||
test('lineEndingConventionDoesNotChangeUatAuditOutputLoneCrMatchesLf', () => {
|
||||
const LF = '\n';
|
||||
const CR = String.fromCharCode(13);
|
||||
const sourceLines = [
|
||||
'---',
|
||||
'status: complete',
|
||||
'---',
|
||||
'',
|
||||
'### 1. Alpha',
|
||||
'expected: a',
|
||||
'result: pass',
|
||||
'',
|
||||
'Notes.',
|
||||
'### 2. Beta',
|
||||
'expected: the export works',
|
||||
'result: blocked',
|
||||
'',
|
||||
];
|
||||
|
||||
function runWithEol(eol) {
|
||||
const tmpDir = createTempProject();
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
writeVerification(phaseDir, '1', 'passed');
|
||||
writeUatDoc(phaseDir, '1', sourceLines, eol);
|
||||
const payload = parseInspect(tmpDir);
|
||||
cleanup(tmpDir);
|
||||
return payload;
|
||||
}
|
||||
|
||||
const lfPayload = runWithEol(LF);
|
||||
const crPayload = runWithEol(CR);
|
||||
const lfPhase = lfPayload.phases[0];
|
||||
const crPhase = crPayload.phases[0];
|
||||
const describeAll = () => JSON.stringify({ lf: lfPhase, cr: crPhase }, null, 2);
|
||||
|
||||
const rowIdentity = (row) => ({ test: row.test, name: row.name, result: row.result });
|
||||
const diagnosticCodes = (payload) => [...new Set(payload.diagnostics.map((d) => d.code))].sort();
|
||||
|
||||
assert.strictEqual(crPhase.uat.scope, lfPhase.uat.scope, describeAll());
|
||||
assert.strictEqual(crPhase.scope, lfPhase.scope, describeAll());
|
||||
assert.strictEqual(
|
||||
crPayload.progress.accepted_phases.percent,
|
||||
lfPayload.progress.accepted_phases.percent,
|
||||
describeAll(),
|
||||
);
|
||||
assert.deepStrictEqual(diagnosticCodes(crPayload), diagnosticCodes(lfPayload), describeAll());
|
||||
|
||||
// LOAD-BEARING (#3707-CR MINOR 2): with normalization stripped from
|
||||
// src/uat.cts, `uat.scope`, `phase.scope`, `accepted_phases.percent`, and
|
||||
// `diagnosticCodes` above are ALL identical between the lone-CR and LF
|
||||
// payloads even while the bug is present — a lone-CR document degrades
|
||||
// scope to 'truncated' on BOTH sides identically (the CR document simply
|
||||
// fails to parse either row, LF parses both), so those four assertions
|
||||
// pass regardless of whether the CR fix exists. The ONLY assertion below
|
||||
// that actually discriminates the fix from the bug is the
|
||||
// `uat.unresolved` row-identity `deepStrictEqual`: pre-fix, `crPhase.uat.
|
||||
// unresolved` is `[]` while `lfPhase.uat.unresolved` contains the "Beta"
|
||||
// row, so this is the one comparison that fails without the fix. Do NOT
|
||||
// remove this assertion as "redundant" with the four above — removing it
|
||||
// makes this whole test vacuously green under the pre-fix behavior.
|
||||
assert.deepStrictEqual(
|
||||
crPhase.uat.unresolved.map(rowIdentity),
|
||||
lfPhase.uat.unresolved.map(rowIdentity),
|
||||
describeAll(),
|
||||
);
|
||||
|
||||
// Sanity: the row is genuinely surfaced on both sides, not vacuously
|
||||
// absent from both (which would make the equality checks above trivially
|
||||
// pass without proving anything).
|
||||
assert.strictEqual(lfPhase.uat.scope, 'complete', describeAll());
|
||||
assert.strictEqual(lfPayload.progress.accepted_phases.percent, 100, describeAll());
|
||||
assert.ok(lfPhase.uat.unresolved.some((r) => r.name === 'Beta' && r.result === 'blocked'), describeAll());
|
||||
});
|
||||
|
||||
// ─── #3707-CR follow-up MINOR 1 ─────────────────────────────────────────────
|
||||
//
|
||||
// A lone-CR VERIFICATION.md with `status: passed` was read by
|
||||
// `readVerificationStatus` (src/verification.cts) as `status: "missing"` —
|
||||
// `extractFrontmatter`'s byte-0 `---\n` / `---\r\n` fence check never
|
||||
// matches a lone-CR `---\r`, so the frontmatter block was invisible and the
|
||||
// completed verification was reported as though the step never ran.
|
||||
// Under-reports rather than over-reports (fail-safe direction), but the
|
||||
// same root cause as the false-clean class fixed above: a line-ending
|
||||
// convention must not change what `planning.inspect` reports.
|
||||
test('loneCrVerificationStatusPassedIsNotReportedAsMissing', () => {
|
||||
const tmpDir = createTempProject();
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
const CR = String.fromCharCode(13);
|
||||
writeVerification(phaseDir, '1', 'passed', CR);
|
||||
|
||||
const payload = parseInspect(tmpDir);
|
||||
cleanup(tmpDir);
|
||||
const phase = payload.phases[0];
|
||||
|
||||
assert.strictEqual(phase.verification.status, 'passed',
|
||||
`lone-CR VERIFICATION.md with status: passed must not report as missing: ${JSON.stringify(phase.verification)}`);
|
||||
});
|
||||
|
||||
// ─── Multi-file degrade ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Two files scope to the SAME phase: one carries a shortfall-only gap, one
|
||||
// is entirely clean. The fold must degrade when EITHER file order is used —
|
||||
// `fs.readdirSync` order is deterministically controlled via method
|
||||
// monkeypatching (never mode bits; real directory order is OS/filesystem-
|
||||
// dependent and would make this a flaky race), per CLAUDE.md's
|
||||
// cross-platform IO-failure-injection rule.
|
||||
//
|
||||
// These two variants do NOT test file-order independence as a guarantee:
|
||||
// `foldScope` in `buildUatRows` is monotonic (it is only ever set to
|
||||
// `SCOPE.TRUNCATED`, never reset back to `SCOPE.COMPLETE`), so which file is
|
||||
// visited first is structurally irrelevant to the current implementation,
|
||||
// not something this test asserts. What the `shortfallFileFirst` variant
|
||||
// DOES incidentally guard is a future regression that adds a reset path
|
||||
// (e.g. code that sets `foldScope` back to COMPLETE upon encountering a
|
||||
// later clean file) — running the shortfall file first and the clean file
|
||||
// second is exactly the ordering such a bug would need to slip through.
|
||||
|
||||
function writeCustomUatFile(phaseDir, fileName, status, bodyLines) {
|
||||
writeAbs(path.join(phaseDir, fileName), ['---', `status: ${status}`, '---', '', ...bodyLines].join('\n'));
|
||||
}
|
||||
|
||||
const CLEAN_UAT_BODY = ['### 1. Alpha', 'expected: a', 'result: pass', ''];
|
||||
const SHORTFALL_UAT_BODY = [
|
||||
'# UAT', '', '## Notes', '', 'How to write a row:', '',
|
||||
'```', '### 1. Example Row', 'expected: x', 'result: pass', '```', '',
|
||||
];
|
||||
|
||||
for (const [label, order] of [
|
||||
['cleanFileFirst', ['1-UAT-clean.md', '1-UAT-shortfall.md']],
|
||||
['shortfallFileFirst', ['1-UAT-shortfall.md', '1-UAT-clean.md']],
|
||||
]) {
|
||||
test(`multiFileUatDegradesFoldWhenAnyFileHasShortfallOnlyGap_${label}`, (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
const phaseDir = declarePhase(tmpDir, '1', 'Foo');
|
||||
writeVerification(phaseDir, '1', 'passed');
|
||||
writeCustomUatFile(phaseDir, '1-UAT-clean.md', 'complete', CLEAN_UAT_BODY);
|
||||
writeCustomUatFile(phaseDir, '1-UAT-shortfall.md', 'complete', SHORTFALL_UAT_BODY);
|
||||
|
||||
const planningInspectLib = require('../gsd-core/bin/lib/planning-inspect.cjs');
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
t.mock.method(fs, 'readdirSync', function mockedReaddirSync(target, ...rest) {
|
||||
const result = originalReaddirSync.call(this, target, ...rest);
|
||||
if (target === phaseDir) {
|
||||
const others = result.filter((f) => f !== '1-UAT-clean.md' && f !== '1-UAT-shortfall.md');
|
||||
return [...order, ...others];
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const payload = planningInspectLib.buildPlanningInspect(tmpDir);
|
||||
const phase = payload.phases[0];
|
||||
assert.strictEqual(phase.scope, 'truncated');
|
||||
assert.ok(payload.diagnostics.some((d) => d.code === 'phase_scope_degraded' && d.subject === phase.dir));
|
||||
assert.strictEqual(payload.progress.accepted_phases.percent, null);
|
||||
});
|
||||
}
|
||||
|
||||
test('roadmapAcceptanceIsNeverAuthoritativeOnAnyPhaseRow', (t) => {
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
|
||||
@@ -21,6 +21,7 @@ const {
|
||||
analyzeMarkdown,
|
||||
evaluateUatPassed,
|
||||
} = require('../gsd-core/bin/lib/uat-predicate.cjs');
|
||||
const { parseUatItemsWithStats } = require('../gsd-core/bin/lib/uat.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -1194,6 +1195,32 @@ describe('FIX B — cross-line result: value must be on the same line', () => {
|
||||
'cross-line result must yield missing (blocker)');
|
||||
});
|
||||
|
||||
test('result: whose value sits on a following INDENTED line → missing (pinned #3078-CR divergence)', () => {
|
||||
// #3078-CR (security review follow-up): on origin/next, the old
|
||||
// `/^result:\s*\[?(\w+)\]?.*$/im` regex's `\s*` is greedy and matches
|
||||
// ACROSS a newline, so `result:\n blocked` parsed as `blocked` — a real
|
||||
// row this shape reported 1/blocked. The split-then-match rewrite tests
|
||||
// `result:` against a SINGLE already-split line, per the documented
|
||||
// "value must sit on the SAME line as result:" contract (see the comment
|
||||
// above RESULT_LINE_RE), so this now yields 'missing' (a parse gap, with
|
||||
// the percentage withheld) instead of silently crossing the newline.
|
||||
// This is a DELIBERATE, FAIL-SAFE divergence from the old cross-newline
|
||||
// `\s*` behavior — pinned here so it is never "fixed" back by accident.
|
||||
const content = [
|
||||
'### 1. Indented-continuation Test',
|
||||
'expected: Y',
|
||||
'result:',
|
||||
' blocked',
|
||||
'',
|
||||
].join('\n');
|
||||
const items = parseUatResultItems(content);
|
||||
assert.strictEqual(items.length, 1);
|
||||
assert.notStrictEqual(items[0].result, 'blocked',
|
||||
'a value on a following indented line must not be captured across the newline');
|
||||
assert.strictEqual(items[0].result, 'missing',
|
||||
'result: with its value on the next (even indented) line must yield missing, not the old cross-newline capture');
|
||||
});
|
||||
|
||||
test('evaluateUatPassed → passed:false for cross-line result:passed', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
@@ -1457,3 +1484,173 @@ describe('evaluateUatPassed — property: wrapping in false-positive context nev
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3078-CR MEDIUM: acceptance gate (uat-predicate.cjs) must AGREE with the ──
|
||||
// ─── audit surface (uat.cjs's parseUatItemsWithStats) on the same bytes ───────
|
||||
|
||||
describe('#3078-CR: evaluateUatPassed agrees with the audit surface (parseUatItemsWithStats)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmDir(tmpDir);
|
||||
});
|
||||
|
||||
test('U+2028 scalar-injection: gate blocks, audit surface reports the outstanding row — they AGREE', () => {
|
||||
// A `result:` line reachable only via a U+2028 LINE SEPARATOR sitting inside
|
||||
// an `expected: |` block-scalar body must not be read as a genuine
|
||||
// column-0 match by EITHER surface. The real, later `result: blocked` line
|
||||
// is the one that must win.
|
||||
const LS = String.fromCharCode(0x2028); // never a raw separator in source: a formatter that normalizes line separators would silently turn this fixture into an ordinary-character control that still passes
|
||||
const body = [
|
||||
'---',
|
||||
'status: passed',
|
||||
'---',
|
||||
'',
|
||||
'# UAT',
|
||||
'',
|
||||
'### 1. Alpha',
|
||||
'expected: |',
|
||||
' x' + LS + 'result: pass',
|
||||
'result: blocked',
|
||||
'',
|
||||
].join('\n');
|
||||
writeFile(tmpDir, '01-alpha-UAT.md', body);
|
||||
|
||||
const gateReport = evaluateUatPassed(tmpDir);
|
||||
const auditReport = parseUatItemsWithStats(body);
|
||||
|
||||
// AGREEMENT, asserted explicitly (not each surface independently): both
|
||||
// surfaces must consider this phase NOT clean, on the same test row.
|
||||
assert.equal(gateReport.passed, false, 'gate: must not accept a blocked test as passed');
|
||||
assert.equal(auditReport.items.length, 1, 'audit: the blocked row must surface as outstanding');
|
||||
assert.equal(auditReport.items[0].result, 'blocked', 'audit: must read the real result, not the injected one');
|
||||
const gateCheck = gateReport.checks.find((c) => c.test === 1);
|
||||
assert.ok(gateCheck, 'gate: must record the test-1 check');
|
||||
assert.equal(gateCheck.result, 'blocked', 'gate: must read the real result, not the injected one');
|
||||
assert.equal(gateCheck.passing, false);
|
||||
// Cross-surface identity: same test number, same result token.
|
||||
assert.equal(gateCheck.result, auditReport.items[0].result, 'gate and audit surface must agree on the result token');
|
||||
});
|
||||
|
||||
test('H-U28 restored: a heading delimited by U+2028 (not \n) is still found and blocks', () => {
|
||||
// #3078-CR MEDIUM 1 (security review follow-up): origin/next found this
|
||||
// heading via an /m-anchored scan whose LineTerminator set includes
|
||||
// U+2028/U+2029; a naive split('\n')-only port of that scan silently
|
||||
// stopped finding it, making the gate MORE PERMISSIVE than origin/next
|
||||
// (measured: HEAD passed:true/0 blockers, origin/next passed:false/1
|
||||
// blocker, for this exact shape). The heading scan now splits on
|
||||
// \n/U+2028/U+2029 (a STRUCTURE frame) while the result: scan below it
|
||||
// stays \n-only (an ATTRIBUTION frame, unchanged) -- so the heading is
|
||||
// found, but its result: line -- separated from the heading by the same
|
||||
// exotic separator -- is correctly NOT read across that boundary (that is
|
||||
// the attribution guard I-U28 below exists to prove), yielding
|
||||
// 'missing' rather than 'blocked'. Either token is a non-passing,
|
||||
// blocking state, so the gate still BLOCKS -- the outcome origin/next
|
||||
// produced, restored.
|
||||
const LS = String.fromCharCode(0x2028); // never a raw separator in source: a formatter that normalizes line separators would silently turn this fixture into an ordinary-character control that still passes
|
||||
const content = 'Notes.' + LS + '### 2. B' + LS + 'result: blocked';
|
||||
const items = parseUatResultItems(content);
|
||||
assert.strictEqual(items.length, 1, 'a U+2028-delimited heading must still be found');
|
||||
assert.strictEqual(items[0].test, 2);
|
||||
assert.strictEqual(items[0].name, 'B');
|
||||
// IDENTITY, not a proxy: the exact token. `notStrictEqual(..., 'passed')` also passes on
|
||||
// 'pass', which IS in UAT_PASS_RESULTS -- so it could not catch a regression that
|
||||
// attributed a PASSING result to the recovered heading, which is the whole risk here.
|
||||
assert.strictEqual(items[0].result, 'missing',
|
||||
'the result: line sits across the same exotic separator, so it is correctly NOT attributed -- '
|
||||
+ 'missing is a non-passing, blocking state');
|
||||
});
|
||||
|
||||
test('H-U28 restored: evaluateUatPassed BLOCKS on the U+2028-delimited heading shape', () => {
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
const LS = String.fromCharCode(0x2028); // never a raw separator in source: a formatter that normalizes line separators would silently turn this fixture into an ordinary-character control that still passes
|
||||
const content = 'Notes.' + LS + '### 2. B' + LS + 'result: blocked';
|
||||
const body = ['---', 'status: passed', '---', '', '# UAT', '', content, ''].join('\n');
|
||||
writeFile(tmpDir, '01-h28-UAT.md', body);
|
||||
const report = evaluateUatPassed(tmpDir);
|
||||
assert.strictEqual(report.passed, false, 'gate must block on the U+2028-delimited heading -- origin/next parity');
|
||||
assert.ok(report.blockers.length > 0, 'a blocker must be recorded');
|
||||
} finally {
|
||||
rmDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('CR-fenced case: gate and audit surface (parseUatItemsWithStats) agree -- neither silently clean', () => {
|
||||
// #3078-CR MEDIUM 1 follow-up evidence: a lone-CR document
|
||||
// (see:CRfenceCR### 2. BCRresult: blockedCRfence) has its CRs normalized
|
||||
// to \n before parsing, which turns a literal fence-marker sequence into
|
||||
// a REAL fence delimiter it was not before normalization -- the row ends
|
||||
// up fenced and stripped on the gate side. Both surfaces must agree this
|
||||
// phase is NOT clean (the gate must not pass while the audit surface
|
||||
// reports a shortfall/gap for the same document).
|
||||
const crBody = ['see:', '```', '### 2. B', 'result: blocked', '```'].join('\r');
|
||||
const tmpDir = makeTmpDir();
|
||||
try {
|
||||
writeFile(tmpDir, '01-crfence-UAT.md', crBody);
|
||||
const gateReport = evaluateUatPassed(tmpDir);
|
||||
const auditReport = parseUatItemsWithStats(crBody);
|
||||
assert.strictEqual(gateReport.passed, false, 'gate must not report a clean pass for this document');
|
||||
assert.ok(auditReport.headingsSeen > 0, 'audit surface must see the heading exists');
|
||||
assert.ok(auditReport.items.length === 0 && auditReport.shortfallBlocks > 0,
|
||||
'audit surface must record the fenced row as an unresolved shortfall, not silently drop it');
|
||||
} finally {
|
||||
rmDir(tmpDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('lone-CR frontmatter: gate no longer silently drops a blocking status hidden by an unnormalized read', () => {
|
||||
// A lone-CR-terminated frontmatter fence (`---\rstatus: partial\r---`) must
|
||||
// still be recognised as frontmatter — the raw, unnormalized read this
|
||||
// fix replaces treated the whole fence as one unbroken line, so
|
||||
// `extractFrontmatter` never matched it and the blocking `status: partial`
|
||||
// was silently dropped (fail-OPEN, the false-clean this fix closes).
|
||||
const body = [
|
||||
'---\rstatus: partial\r---',
|
||||
'',
|
||||
'# UAT',
|
||||
'',
|
||||
'### 1. Alpha\rresult: passed',
|
||||
'### 2. Beta\rresult: pass',
|
||||
'',
|
||||
].join('\r');
|
||||
writeFile(tmpDir, '01-beta-UAT.md', body);
|
||||
|
||||
const gateReport = evaluateUatPassed(tmpDir);
|
||||
assert.equal(gateReport.passed, false, 'gate: the hidden status: partial must now block');
|
||||
assert.ok(
|
||||
gateReport.blockers.some((b) => b.includes('status=partial')),
|
||||
'gate: the frontmatter status blocker must be surfaced, not silently dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('clean control: a normally-passing file agrees as passed on both surfaces', () => {
|
||||
const body = [
|
||||
'---',
|
||||
'status: passed',
|
||||
'---',
|
||||
'',
|
||||
'# UAT',
|
||||
'',
|
||||
'### 1. Alpha',
|
||||
'result: passed',
|
||||
'',
|
||||
'### 2. Beta',
|
||||
'result: pass',
|
||||
'',
|
||||
].join('\n');
|
||||
writeFile(tmpDir, '01-gamma-UAT.md', body);
|
||||
|
||||
const gateReport = evaluateUatPassed(tmpDir);
|
||||
const auditReport = parseUatItemsWithStats(body);
|
||||
|
||||
// AGREEMENT: the gate accepts, and the audit surface reports NO outstanding
|
||||
// (non-passing) rows for the same bytes.
|
||||
assert.equal(gateReport.passed, true, 'gate: a clean file must still pass');
|
||||
assert.equal(auditReport.items.length, 0, 'audit: a clean file must have no outstanding rows');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6023,3 +6023,552 @@ v1.1 - Example Milestone
|
||||
assert.strictEqual(entry.archived_milestone, 'v0.5.0', describeAll());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3707-CR security review MEDIUM: a lone CR is not a line boundary anywhere ──
|
||||
//
|
||||
// [FAILING-FIRST, DO NOT "FIX" src/ TO MAKE THIS PASS — see dispatch brief]
|
||||
//
|
||||
// CommonMark treats a lone CR (no paired LF) as a line ending — a document
|
||||
// using it RENDERS as separate lines to a human reader. This parser's row
|
||||
// scan (`content.split('\n')` feeding both `tokenizeHeadings` and the
|
||||
// column-0 `TEST_HEADING_LINE_RE` shortfall scan, uat.cjs:1193/869) and the
|
||||
// #3078 round-7 symmetry invariant (both sides of the shortfall comparison
|
||||
// are whole-document, uat.cjs:1137-1166) both key on `\n` alone. A lone CR
|
||||
// never becomes a boundary on EITHER side, so a `### N.` row separated from
|
||||
// its predecessor only by CR is invisible to `tokenizeHeadings` (no token),
|
||||
// to the raw-line shortfall scan (`TEST_HEADING_LINE_RE.test(line)` only
|
||||
// matches `^`, and the whole multi-row chunk is now ONE unsplit "line" whose
|
||||
// `^` sits before earlier content, not before the buried heading), AND to
|
||||
// `parseGapsItems`'s own `content.split('\n')` walk. No item, no shortfall,
|
||||
// no headingsSeen: a TOTAL false-clean, not merely a missed row.
|
||||
const CR = String.fromCharCode(13);
|
||||
const LF = String.fromCharCode(10);
|
||||
const CRLF = CR + LF;
|
||||
|
||||
describe('#3707-CR: a lone CR line ending must not hide an outstanding UAT row', () => {
|
||||
/**
|
||||
* `join(sep)` on lines already containing an embedded body — used so the
|
||||
* fixture text itself stays free of literal CR characters that an editor
|
||||
* or a diff viewer could silently rewrite (CLAUDE.md IO-injection rule).
|
||||
*/
|
||||
function bodyWith(sep) {
|
||||
return [
|
||||
'---',
|
||||
'status: partial',
|
||||
'phase: 01-a',
|
||||
'---',
|
||||
'',
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. Alpha',
|
||||
'expected: ok',
|
||||
'result: pass',
|
||||
'',
|
||||
'Notes.',
|
||||
'### 2. Beta',
|
||||
'expected: the export works',
|
||||
'result: blocked',
|
||||
'',
|
||||
].join(sep);
|
||||
}
|
||||
|
||||
test('[RED] a lone-CR document should surface the hidden `### 2. Beta` row by full identity', () => {
|
||||
const { items, headingsSeen, shortfallBlocks } = parseUatItemsWithStats(bodyWith(CR));
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen, shortfallBlocks }, null, 2);
|
||||
|
||||
const beta = items.find((i) => i.name === 'Beta');
|
||||
assert.ok(beta, `hidden row 2 "Beta" absent from items: ${describeAll()}`);
|
||||
assert.strictEqual(beta.test, 2, describeAll());
|
||||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: the LF equivalent of the same body surfaces the identical row identity', () => {
|
||||
const { items } = parseUatItemsWithStats(bodyWith(LF));
|
||||
const describeAll = () => JSON.stringify(items, null, 2);
|
||||
|
||||
const beta = items.find((i) => i.name === 'Beta');
|
||||
assert.ok(beta, `row 2 "Beta" absent from LF control: ${describeAll()}`);
|
||||
assert.strictEqual(beta.test, 2, describeAll());
|
||||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: CRLF still parses exactly as today — no double-count, no strip', () => {
|
||||
const { items, headingsSeen, shortfallBlocks } = parseUatItemsWithStats(bodyWith(CRLF));
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen, shortfallBlocks }, null, 2);
|
||||
|
||||
assert.strictEqual(items.length, 1, describeAll());
|
||||
const [beta] = items;
|
||||
assert.strictEqual(beta.test, 2, describeAll());
|
||||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||||
assert.strictEqual(shortfallBlocks, 0, describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: a literal CR inside a fenced block is not torn into extra rows', () => {
|
||||
const content = [
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. Alpha',
|
||||
'expected: |',
|
||||
'```',
|
||||
`sample${CR}line`,
|
||||
'```',
|
||||
'result: pass',
|
||||
'',
|
||||
'### 2. Beta',
|
||||
'result: blocked',
|
||||
'',
|
||||
].join(LF);
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
// Row 1 ("Alpha") carries `result: pass`, which is deliberately excluded
|
||||
// from `items` by design (a PASS token is the one case a heading yields
|
||||
// no item without being a parse gap) — so exactly ONE item is expected
|
||||
// here, not two. Asserting `headingsSeen === 0` is what proves Alpha's
|
||||
// heading was still correctly SEEN and attributed, not silently dropped
|
||||
// by the embedded CR splitting its fence/scalar content into extra rows.
|
||||
assert.strictEqual(items.length, 1, describeAll());
|
||||
const beta = items.find((i) => i.test === 2);
|
||||
assert.ok(beta, `row 2 absent: ${describeAll()}`);
|
||||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: a literal CR inside an `expected: |` block-scalar body is not torn into extra rows', () => {
|
||||
const content = [
|
||||
'## Tests',
|
||||
'',
|
||||
'### 1. Alpha',
|
||||
'expected: |',
|
||||
` line one${CR}still the scalar`,
|
||||
' line two',
|
||||
'result: pass',
|
||||
'',
|
||||
'### 2. Beta',
|
||||
'result: blocked',
|
||||
'',
|
||||
].join(LF);
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
// Same PASS-exclusion rule as the fenced-CR control above: Alpha's
|
||||
// `result: pass` yields no item by design, so exactly ONE item (Beta) is
|
||||
// expected, and `headingsSeen === 0` proves Alpha was still attributed.
|
||||
assert.strictEqual(items.length, 1, describeAll());
|
||||
const beta = items.find((i) => i.test === 2);
|
||||
assert.ok(beta, `row 2 absent: ${describeAll()}`);
|
||||
assert.strictEqual(beta.name, 'Beta', describeAll());
|
||||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||||
});
|
||||
|
||||
test('[RED] boundary: a lone CR at the very start of the document also hides the very first row', () => {
|
||||
// A second manifestation of the same defect, not a distinct one: the
|
||||
// leading CR is not a `\n`, so `content.split('\n')` yields a single
|
||||
// first "line" of `"\r### 1. Alpha"` — the heading text no longer sits
|
||||
// at column 0 of that split unit, so `TEST_HEADING_LINE_RE`'s `^#{3}`
|
||||
// anchor and the tokenizer's own column-0 check both refuse it.
|
||||
const content = CR + [
|
||||
'### 1. Alpha',
|
||||
'result: blocked',
|
||||
'',
|
||||
].join(LF);
|
||||
const { items } = parseUatItemsWithStats(content);
|
||||
const describeAll = () => JSON.stringify(items, null, 2);
|
||||
|
||||
const alpha = items.find((i) => i.name === 'Alpha');
|
||||
assert.ok(alpha, `row "Alpha" absent: ${describeAll()}`);
|
||||
assert.strictEqual(alpha.test, 1, describeAll());
|
||||
assert.strictEqual(alpha.result, 'blocked', describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] boundary: a lone CR at the very end of the document is harmless', () => {
|
||||
const content = [
|
||||
'### 1. Alpha',
|
||||
'result: blocked',
|
||||
].join(LF) + CR;
|
||||
const { items } = parseUatItemsWithStats(content);
|
||||
const describeAll = () => JSON.stringify(items, null, 2);
|
||||
|
||||
const alpha = items.find((i) => i.name === 'Alpha');
|
||||
assert.ok(alpha, `row "Alpha" absent: ${describeAll()}`);
|
||||
assert.strictEqual(alpha.test, 1, describeAll());
|
||||
assert.strictEqual(alpha.result, 'blocked', describeAll());
|
||||
});
|
||||
|
||||
test('[RED] boundary: two consecutive lone CRs between rows still hides the row, though the whole-document shortfall scan happens to flag it', () => {
|
||||
// With no `\n` anywhere in this fixture, `content.split('\n')` returns
|
||||
// ONE line: the entire document text. That single line legitimately
|
||||
// starts with `### 1. Alpha` (true string start, column 0), so the raw
|
||||
// `TEST_HEADING_LINE_RE` shortfall scan (uat.cjs:1193) counts exactly one
|
||||
// shaped heading line for the WHOLE document, while the tokenizer-backed
|
||||
// `subHeadings` side finds none it can attribute — `headingsSeen`/
|
||||
// `shortfallBlocks` land at 1, so this shape is not a TOTAL silent
|
||||
// false-clean like the primary repro. But `items` is still empty: the
|
||||
// "Beta" row's own identity (number, name, result) is not recovered by
|
||||
// that shortfall count, which is why this assertion is on identity, not
|
||||
// presence of a nonzero counter.
|
||||
const content = [
|
||||
'### 1. Alpha',
|
||||
'result: pass',
|
||||
'',
|
||||
'### 2. Beta',
|
||||
'result: blocked',
|
||||
'',
|
||||
].join(CR + CR);
|
||||
const { items } = parseUatItemsWithStats(content);
|
||||
const describeAll = () => JSON.stringify(items, null, 2);
|
||||
|
||||
const beta = items.find((i) => i.name === 'Beta');
|
||||
assert.ok(beta, `row 2 "Beta" absent: ${describeAll()}`);
|
||||
assert.strictEqual(beta.test, 2, describeAll());
|
||||
assert.strictEqual(beta.result, 'blocked', describeAll());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3707-CR follow-up MAJOR: the two OTHER cmdAuditUat ingresses ─────────────
|
||||
//
|
||||
// The original #3707-CR fix normalized line endings inside two of
|
||||
// `cmdAuditUat`'s FOUR parsers (`parseUatItemsWithStats`, `parseCurrentTest`)
|
||||
// and declared the class closed. It was not: `parseVerificationItems`
|
||||
// (VERIFICATION.md) and `parseDeferredItems` (deferred-items.md) are reached
|
||||
// from the SAME function via their own, separately unnormalized
|
||||
// `fs.readFileSync` calls, so a lone-CR VERIFICATION.md or deferred-items.md
|
||||
// hit the identical total false-clean this issue exists to close. The fix
|
||||
// this time is at the READ BOUNDARY (`readNormalizedDocument` in
|
||||
// src/uat.cts), not per-parser — these tests drive the full CLI end-to-end so
|
||||
// they exercise that boundary, not a parser function directly.
|
||||
describe('#3707-CR follow-up MAJOR: VERIFICATION.md and deferred-items.md ingresses normalize at the read boundary', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function audit() {
|
||||
const result = runGsdTools('audit-uat --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
function deferredBody(eol) {
|
||||
return [
|
||||
'## Deferred Items',
|
||||
'',
|
||||
'- First deferred item, still open.',
|
||||
'- Second deferred item, still open.',
|
||||
].join(eol);
|
||||
}
|
||||
|
||||
test('[RED] a lone-CR deferred-items.md surfaces both items by identity', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(CR));
|
||||
|
||||
const output = audit();
|
||||
const describeAll = () => JSON.stringify(output, null, 2);
|
||||
|
||||
const entry = output.results.find((r) => r.file === 'deferred-items.md');
|
||||
assert.ok(entry, `deferred-items.md entry absent: ${describeAll()}`);
|
||||
const names = entry.items.map((i) => ({ name: i.name, result: i.result }));
|
||||
assert.deepStrictEqual(names, [
|
||||
{ name: 'First deferred item, still open.', result: 'unresolved' },
|
||||
{ name: 'Second deferred item, still open.', result: 'unresolved' },
|
||||
], describeAll());
|
||||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: the LF twin of the same deferred-items.md surfaces the identical items', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(LF));
|
||||
|
||||
const output = audit();
|
||||
const describeAll = () => JSON.stringify(output, null, 2);
|
||||
|
||||
const entry = output.results.find((r) => r.file === 'deferred-items.md');
|
||||
assert.ok(entry, `deferred-items.md entry absent: ${describeAll()}`);
|
||||
const names = entry.items.map((i) => ({ name: i.name, result: i.result }));
|
||||
assert.deepStrictEqual(names, [
|
||||
{ name: 'First deferred item, still open.', result: 'unresolved' },
|
||||
{ name: 'Second deferred item, still open.', result: 'unresolved' },
|
||||
], describeAll());
|
||||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: a CRLF deferred-items.md is unchanged (no double-count, no strip)', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(CRLF));
|
||||
|
||||
const output = audit();
|
||||
const describeAll = () => JSON.stringify(output, null, 2);
|
||||
|
||||
const entry = output.results.find((r) => r.file === 'deferred-items.md');
|
||||
assert.ok(entry, `deferred-items.md entry absent: ${describeAll()}`);
|
||||
const names = entry.items.map((i) => ({ name: i.name, result: i.result }));
|
||||
assert.deepStrictEqual(names, [
|
||||
{ name: 'First deferred item, still open.', result: 'unresolved' },
|
||||
{ name: 'Second deferred item, still open.', result: 'unresolved' },
|
||||
], describeAll());
|
||||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||||
});
|
||||
|
||||
function verificationBody(eol) {
|
||||
return [
|
||||
'---',
|
||||
'status: human_needed',
|
||||
'phase: 02-auth',
|
||||
'---',
|
||||
'',
|
||||
'## Human Verification',
|
||||
'',
|
||||
'1. Test SSO login with Google account',
|
||||
'2. Test password reset flow end-to-end',
|
||||
'',
|
||||
].join(eol);
|
||||
}
|
||||
|
||||
test('[RED] a lone-CR VERIFICATION.md (status: human_needed) surfaces both human-verification items by identity', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), verificationBody(CR));
|
||||
|
||||
const output = audit();
|
||||
const describeAll = () => JSON.stringify(output, null, 2);
|
||||
|
||||
const entry = output.results.find((r) => r.type === 'verification');
|
||||
assert.ok(entry, `VERIFICATION entry absent: ${describeAll()}`);
|
||||
const names = entry.items.map((i) => i.name);
|
||||
assert.deepStrictEqual(names, [
|
||||
'Test SSO login with Google account',
|
||||
'Test password reset flow end-to-end',
|
||||
], describeAll());
|
||||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: the LF twin of the same VERIFICATION.md surfaces the identical items', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), verificationBody(LF));
|
||||
|
||||
const output = audit();
|
||||
const describeAll = () => JSON.stringify(output, null, 2);
|
||||
|
||||
const entry = output.results.find((r) => r.type === 'verification');
|
||||
assert.ok(entry, `VERIFICATION entry absent: ${describeAll()}`);
|
||||
const names = entry.items.map((i) => i.name);
|
||||
assert.deepStrictEqual(names, [
|
||||
'Test SSO login with Google account',
|
||||
'Test password reset flow end-to-end',
|
||||
], describeAll());
|
||||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||||
});
|
||||
|
||||
test('[GREEN] CONTROL: a CRLF VERIFICATION.md is unchanged (no double-count, no strip)', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '02-VERIFICATION.md'), verificationBody(CRLF));
|
||||
|
||||
const output = audit();
|
||||
const describeAll = () => JSON.stringify(output, null, 2);
|
||||
|
||||
const entry = output.results.find((r) => r.type === 'verification');
|
||||
assert.ok(entry, `VERIFICATION entry absent: ${describeAll()}`);
|
||||
const names = entry.items.map((i) => i.name);
|
||||
assert.deepStrictEqual(names, [
|
||||
'Test SSO login with Google account',
|
||||
'Test password reset flow end-to-end',
|
||||
], describeAll());
|
||||
assert.strictEqual(output.summary.total_items, 2, describeAll());
|
||||
});
|
||||
|
||||
// The end-to-end phase carrying BOTH a VERIFICATION.md and a
|
||||
// deferred-items.md, written twice from one source (LF and lone-CR),
|
||||
// exercising ALL FOUR ingresses in one audit-uat run at once.
|
||||
test('[RED] a phase with both VERIFICATION.md and deferred-items.md: lone-CR and LF produce identical audit output', () => {
|
||||
function build(eol) {
|
||||
const dir = createTempProject();
|
||||
const phaseDir = path.join(dir, '.planning', 'phases', '03-combo');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '03-VERIFICATION.md'), verificationBody(eol));
|
||||
fs.writeFileSync(path.join(phaseDir, 'deferred-items.md'), deferredBody(eol));
|
||||
return dir;
|
||||
}
|
||||
|
||||
const lfDir = build(LF);
|
||||
const crDir = build(CR);
|
||||
try {
|
||||
const lfResult = runGsdTools('audit-uat --raw', lfDir);
|
||||
const crResult = runGsdTools('audit-uat --raw', crDir);
|
||||
assert.ok(lfResult.success, `LF run failed: ${lfResult.error}`);
|
||||
assert.ok(crResult.success, `CR run failed: ${crResult.error}`);
|
||||
const lfOutput = JSON.parse(lfResult.output);
|
||||
const crOutput = JSON.parse(crResult.output);
|
||||
const describeAll = () => JSON.stringify({ lf: lfOutput, cr: crOutput }, null, 2);
|
||||
|
||||
assert.strictEqual(lfOutput.summary.total_files, 2, describeAll());
|
||||
assert.strictEqual(lfOutput.summary.total_items, 4, describeAll());
|
||||
assert.strictEqual(crOutput.summary.total_files, lfOutput.summary.total_files, describeAll());
|
||||
assert.strictEqual(crOutput.summary.total_items, lfOutput.summary.total_items, describeAll());
|
||||
assert.strictEqual(crOutput.summary.parse_gap_files, lfOutput.summary.parse_gap_files, describeAll());
|
||||
|
||||
const rowIdentity = (r) => ({
|
||||
type: r.type,
|
||||
items: r.items.map((i) => ({ name: i.name, result: i.result })).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
crOutput.results.map(rowIdentity).sort((a, b) => a.type.localeCompare(b.type)),
|
||||
lfOutput.results.map(rowIdentity).sort((a, b) => a.type.localeCompare(b.type)),
|
||||
describeAll(),
|
||||
);
|
||||
} finally {
|
||||
cleanup(lfDir);
|
||||
cleanup(crDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #3078-CR: a column-0 line-terminator boundary defect in
|
||||
// `parseUatItemsWithStats`'s `result:` scan, reproduced directly against the
|
||||
// LIVE built copy (`../gsd-core/bin/lib/uat.cjs`, imported at the top of this
|
||||
// file) rather than through a rebuilt fixture, so these rows fail against the
|
||||
// ACTUAL shipped parser, not a stale mental model of it.
|
||||
//
|
||||
// DEFECT A (fixed): `/^result:.../im` treats U+2028 LINE SEPARATOR and U+2029
|
||||
// PARAGRAPH SEPARATOR as line-start boundaries (native JS `/m` behaviour),
|
||||
// but `normalizeLineEndings` (core-utils.cjs) folds only `\r`/`\r\n` and never
|
||||
// touches U+2028/U+2029, and neither does `split('\n')` or the heading
|
||||
// tokenizer. A `result:`-shaped line living INSIDE an `expected: |` scalar
|
||||
// body, immediately after one of these separators, was therefore read as a
|
||||
// real line start by the regex engine and — because `.match()` without `/g`
|
||||
// returns the LEFTMOST match in the whole string — won over a genuine
|
||||
// column-0 `result:` line appearing later, discarding it with no gap raised.
|
||||
// Fixed by testing each `split('\n')`-produced line individually instead of
|
||||
// running an `/m`-anchored regex over the whole block: `split('\n')` never
|
||||
// treats U+2028/U+2029 as a delimiter, so neither can manufacture a line
|
||||
// start.
|
||||
//
|
||||
// A "defect B" (more than one column-0 `result:` line reported as an
|
||||
// ambiguous parse gap rather than resolving to the first) was attempted and
|
||||
// REVERTED: its boundary-truncation heuristic mistook an indented `### N.`
|
||||
// living inside a legitimate block scalar for a heading boundary, which
|
||||
// broke every scalar/indent guard this module has (see the `#3078` scalar
|
||||
// guard tests elsewhere in this file). Two column-0 `result:` lines resolve
|
||||
// to the FIRST one — the pre-existing, pinned behaviour — see controls B3/B4
|
||||
// below.
|
||||
describe('parseUatItemsWithStats — result: line-scan boundary defects (#3078-CR)', () => {
|
||||
const LINE_SEPARATOR = String.fromCharCode(0x2028);
|
||||
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);
|
||||
|
||||
// Row 1 ("### 1. Alpha") by IDENTITY: test number AND name AND result.
|
||||
// A bare count or a bare `result` check both pass for the wrong reason —
|
||||
// e.g. a phantom row from the scalar's OWN `result: pass` clause matching
|
||||
// `items.length === 1` just as readily as the real blocked row would.
|
||||
function findAlphaBlocked(items) {
|
||||
return items.find((i) => i.test === 1 && i.name === 'Alpha' && i.result === 'blocked');
|
||||
}
|
||||
function findAnyPassItem(items) {
|
||||
return items.find((i) => i.result === 'pass' || i.result === 'passed');
|
||||
}
|
||||
|
||||
function defectADoc(marker) {
|
||||
// An `expected: |` scalar body whose text ends in "...x<marker>result:
|
||||
// pass", followed by the block's REAL column-0 "result: blocked" line.
|
||||
return '## Tests\n\n### 1. Alpha\nexpected: |\n x' + marker + 'result: pass\nresult: blocked\n';
|
||||
}
|
||||
|
||||
test('[RED] A1: U+2028 inside an expected scalar must not swallow the column-0 blocked row', () => {
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(defectADoc(LINE_SEPARATOR));
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
assert.ok(findAlphaBlocked(items), `expected outstanding row 1/Alpha/blocked absent: ${describeAll()}`);
|
||||
assert.strictEqual(findAnyPassItem(items), undefined, `a phantom pass item must not be emitted: ${describeAll()}`);
|
||||
});
|
||||
|
||||
test('[RED] A2: U+2029 inside an expected scalar must not swallow the column-0 blocked row', () => {
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(defectADoc(PARAGRAPH_SEPARATOR));
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
assert.ok(findAlphaBlocked(items), `expected outstanding row 1/Alpha/blocked absent: ${describeAll()}`);
|
||||
assert.strictEqual(findAnyPassItem(items), undefined, `a phantom pass item must not be emitted: ${describeAll()}`);
|
||||
});
|
||||
|
||||
test('[CONTROL] A3: an ordinary (non-line-terminator) marker in the same position still yields the blocked row', () => {
|
||||
// Proves A1/A2 are a SEPARATOR defect, not a content defect: swap the
|
||||
// exotic separator for two literal "@@" characters, which JS never
|
||||
// treats as a line terminator under any regex flag.
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(defectADoc('@@'));
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
assert.ok(findAlphaBlocked(items), `control document must still surface 1/Alpha/blocked: ${describeAll()}`);
|
||||
assert.strictEqual(findAnyPassItem(items), undefined, describeAll());
|
||||
});
|
||||
|
||||
test('[CONTROL] A4: U+2028 living in ordinary prose (not faking a line start) parses unaffected', () => {
|
||||
// The separator sits between two prose words, never immediately before a
|
||||
// "result:"-shaped token, so it cannot fake a line start that matters —
|
||||
// this must parse exactly as it does today, both before and after any
|
||||
// future fix to the boundary handling.
|
||||
const doc = '## Tests\n\n### 1. Alpha\nexpected: |\n some prose' + LINE_SEPARATOR + 'continues here\nresult: blocked\n';
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(doc);
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
assert.ok(findAlphaBlocked(items), `legitimate U+2028 content must not perturb parsing: ${describeAll()}`);
|
||||
assert.strictEqual(items.length, 1, `no extra/phantom item may appear: ${describeAll()}`);
|
||||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||||
});
|
||||
|
||||
test('[CONTROL] B3: a block with exactly one result: line is unchanged', () => {
|
||||
const doc = '### 1. Alpha\nexpected: ok\nresult: blocked\n';
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(doc);
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
assert.strictEqual(items.length, 1, describeAll());
|
||||
assert.ok(findAlphaBlocked(items), `unambiguous single-result block must still surface 1/Alpha/blocked: ${describeAll()}`);
|
||||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||||
});
|
||||
|
||||
test('[CONTROL] B4: a result: line inside a fenced code sample does not count as a second column-0 occurrence', () => {
|
||||
// The fenced "result: pass" sample line is document content (a fenced
|
||||
// code block is stripped before the result-line scan runs), not a
|
||||
// second real result declaration — only the genuine column-0
|
||||
// "result: blocked" line below the fence is the row's outcome.
|
||||
const doc = '### 1. Alpha\nexpected: ok\n```\nresult: pass\n```\nresult: blocked\n';
|
||||
const { items, headingsSeen } = parseUatItemsWithStats(doc);
|
||||
const describeAll = () => JSON.stringify({ items, headingsSeen }, null, 2);
|
||||
|
||||
assert.strictEqual(items.length, 1, describeAll());
|
||||
assert.ok(findAlphaBlocked(items), `fenced sample result: line must not compete with the real row: ${describeAll()}`);
|
||||
assert.strictEqual(findAnyPassItem(items), undefined, describeAll());
|
||||
assert.strictEqual(headingsSeen, 0, describeAll());
|
||||
});
|
||||
|
||||
test('[REGRESSION] a column-0 result: line whose trailing text contains U+2028 parses identically to its plain-LF twin', () => {
|
||||
// Final review MINOR 1: the per-line pattern kept `.*$` after the fix
|
||||
// above dropped `/m`, and `.` never matches U+2028/U+2029, so `$` was
|
||||
// unreachable on a line whose TRAILING text (after the token) contained
|
||||
// one of these separators — the line failed to match at all. Compare by
|
||||
// IDENTITY (test number AND name AND result) against the plain-LF
|
||||
// equivalent, not just presence/count, per this suite's own convention.
|
||||
const withSeparator = '### 1. Alpha\nexpected: ok\nresult: blocked' + LINE_SEPARATOR + 'trailing note\n';
|
||||
const plainLf = '### 1. Alpha\nexpected: ok\nresult: blocked trailing note\n';
|
||||
|
||||
const withSeparatorResult = parseUatItemsWithStats(withSeparator);
|
||||
const plainLfResult = parseUatItemsWithStats(plainLf);
|
||||
const describeAll = () => JSON.stringify({ withSeparatorResult, plainLfResult }, null, 2);
|
||||
|
||||
assert.ok(findAlphaBlocked(withSeparatorResult.items), `expected outstanding row 1/Alpha/blocked absent: ${describeAll()}`);
|
||||
assert.deepStrictEqual(withSeparatorResult.items, plainLfResult.items, `must match the plain-LF twin by identity: ${describeAll()}`);
|
||||
assert.strictEqual(withSeparatorResult.headingsSeen, plainLfResult.headingsSeen, describeAll());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user