* test(#3413): failing-first suite for the line-terminator seam Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Tests only — src/text-lines.cts does not exist yet, so tests/text-lines.test.cjs fails with MODULE_NOT_FOUND at its require line, which is the intended RED. The frontmatter.test.cjs additions drive #3360 (confirmed-bug) fail-first: parseMustHavesBlock currently returns [] for every must_haves block on a CRLF-authored plan file, because \r is its own LineTerminator in ECMAScript and two /m-anchored \s* patterns can absorb it, inflating a captured indent by one character and tripping the "not nested under must_haves" guard. Verified locally against the current (unfixed) compiled module: both the direct repro and the silent-exit "blank line before must_haves:" variant return [] today. A parity property test (crlf vs lf must deep-equal for every block name) matches a pattern this maintainer has required repeatedly for prior CRLF fixes in this codebase (Cortex-recorded, verify_intent=held). The no-crlf-fragile-split.rule.test.cjs additions lock the eslint rule's future fix-hint text (pointing at splitLines()) and its self-reference non-violation (the seam's own correct \r?\n split must never flag itself). Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md * chore(#3413): src/text-lines.cts owns line-terminator handling Phase 2 of epic #3212 (ADR-3212 §3/§6/§7). Adds splitLines/normalizeEol/ detectEol/joinLines and migrates frontmatter.cts onto it. parseMustHavesBlock (#3360, confirmed-bug) returned [] for every must_haves block on a CRLF plan file. Root cause: \r is its own LineTerminator in ECMAScript, so under /m two \s*-anchored indentation lookups could match at the position INSIDE a \r\n pair and absorb the terminator, inflating the captured indent by one character and tripping the "not nested under must_haves" guard. Two silent exits, one with a diagnostic and one without (a blank line before must_haves: hits the silent path). Fixed by converting both lookups from a whole-string /m match to split-then-scan — splitLines first, then a per-line, non-/m match — the same structural pattern parseYamlRegion (30 lines away in the same file) already used safely. Nothing downstream of the two lookups changed; blockLines is now sliced from the already-split array instead of re-splitting a substring, but its contents are unchanged for LF input, and the per-line dash/kv parsing loop is untouched. A parity property test (CRLF and LF plans parse to identical must_haves for every block name) matches a pattern this maintainer has required repeatedly for prior CRLF fixes in this file's neighborhood (Cortex: 7 recorded decisions, verify_intent -> held). frontmatter.cts's other .split(/\r?\n/) call sites (parseYamlRegion, isFrontmatterShaped, sliceTopLevelFrontmatterSegments, spliceFrontmatter) are rerouted onto splitLines — a literal 1:1 substitution, zero behavior change, since splitLines IS that same regex plus a type guard. The 4 scripts/normalizeLineEndings copies (gen-registry, gen-loop-host- contract, gen-capability-registry, gen-context-index) are deleted and rerouted onto normalizeEol, which strips a bare unpaired \r exactly like the deleted copies did (not just \r\n pairs) -- verified against each script's own --check mode against its real generated output. local/no-crlf-fragile-split widens from tests/ to src/**/*.cts, with its fix-hint message now naming splitLines() instead of the raw regex -- the prohibition finally has a primitive to point at. Detection logic unchanged in this phase (deliberate scope limit, see design doc Known limits: the rule doesn't yet recognize safeReadFile/platformReadSync as a content source, and has no detector for the \s-adjacent-to-anchor shape that is #3360's actual mechanism -- the CLASS is converged by the direct fix + regression test regardless). joinLines/detectEol are NOT wired into frontmatter.cts's own write path (cmdFrontmatterSet/Merge -> platformWriteSync) -- verified that platformWriteSync already, unconditionally converts CRLF->LF on every .md write today as a pre-existing policy owned by a different module, and ADR-3212's backward-compatibility clause rules out a file-format change in any phase. Stated explicitly in Known limits rather than left for a reader to discover. Six-gate ripple: .gitignore, eslint.config.mjs (src/**/*.cts block), docs/INVENTORY.md + INVENTORY-MANIFEST.json (regenerated), CONTEXT.md glossary (Text Lines Module, mirroring Phase 1's Pattern Module entry). Design: .gsd/phase/chore-3413-text-lines-seam/40-design.md Test matrix: .gsd/phase/chore-3413-text-lines-seam/50-test-matrix.md * fix(#3413): fix 13 pre-existing CRLF-fragile splits the widened rule found Widening local/no-crlf-fragile-split from tests/ to src/**/*.cts (the previous commit) immediately surfaced 13 real, pre-existing violations across 10 files -- undetected until now because the rule never scanned src/. This is the exact defect class ADR-3212 exists to close, playing out again one phase after Phase 1 hit the same shape ("the new lint rule -- once live -- found 27 more"). Per CLAUDE.md's no-defer rule, fixed inline rather than deferred or suppressed; there is no established suppression convention for this rule in src/ and inventing one now would undermine the point of widening it. audit.cts, broken-windows.cts, core-utils.cts, init.cts, milestone.cts, phase.cts (x3), profile-output.cts, roadmap.cts (x2): bare-\n splits or regex character classes widened to \r?\n / [^\r\n], each following the same pattern already established migrating frontmatter.cts. phase-estimation.cts: `\r?(?:\n|$)` restructured to `(?:\r?\n|\r?$)` -- already semantically CRLF-safe, but the rule's lexical scanner doesn't recognize \r? guarding a group (only \r? immediately before a literal \n). Verified the two forms are equivalent across all four EOL/EOF cases before restructuring, not assumed. roadmap-upgrade.cts needed two coupled sites, not the one flagged line: computeMigrationPlan and applyMigration must agree on line representation for the lines[edit.lineIndex] === edit.from equality check to hold, and the write-back needed joinLines + detectEol -- a plain lines.join('\n') was silently flattening a CRLF ROADMAP.md to LF wholesale on every migration. This is the first real production consumer of joinLines/detectEol in this epic (frontmatter.cts's own write path doesn't use them -- see the previous commit's Known limits). Fixing the 13 flagged sites surfaced 4 more adjacent same-shape sites the rule doesn't track (.search() and new RegExp(dynamicString) aren't in its tracked call/construction set). Investigated each empirically -- hand-tracing this exact bug class already produced one wrong conclusion earlier in this phase (a detectEol design-doc arithmetic error), so these were verified with real CRLF fixtures rather than reasoned about on paper: - audit.cts (scanTodos): REAL bug, fixed. `bodyMatch.trim().split ('\n')[0]` leaked a trailing \r into a user-visible todo summary on CRLF input -- .trim() only strips the string's outer edges, not a \r sitting mid-string before the first bare \n. Now splitLines(...) [0]. - phase.cts (cmdPhaseInsert, bullet-style branch): REAL bug, fixed. [^\n]* in targetBulletPattern swallowed a line's trailing \r on CRLF input, shifting the computed insert position to land INSIDE the \r\n pair; combined with a hardcoded '\n' bullet separator, a CRLF ROADMAP.md ended up with a mixed CRLF/LF result after an insert. Fixed with two coupled changes (either alone still corrupts, verified both ways): [^\r\n]* in the pattern, and the new bullet's leading terminator now comes from detectEol(rawContent). - roadmap.cts (cmdRoadmapAnnotateDependencies phase-boundary scan): investigated, genuinely safe, left untouched. The .search(/\n#{2,4} .../) boundary-finder and the [^\n]*-based heading match were empirically verified on a 3-phase CRLF fixture -- the only stray \r ends up at the tail of an intermediate phaseSection string that is only ever used for .test()-based idempotency checks, never for an exact-match comparison or written back to disk. No corruption on round-trip. Every fix re-verified: npm run build:lib clean, npx eslint 'src/**/*.cts' --no-cache reports 0 problems (was 13), and each fixed function's existing LF-input tests were spot-checked unchanged. * fix(#3413): apply orthogonal review findings Two isolated review engines (correctness + security) ran against the full diff and found three majors, one real security issue, and several disclosure-worthy minors. All fixed or explicitly disclosed with evidence; nothing deferred. MAJOR — detectEol's tie-break contradicted its own documented contract. Code returned '\n' on a 1:1 crlf/bare-LF tie; every doc (design doc, CONTEXT.md, the function's own comment) says ties resolve to '\r\n'. The existing test masked this by reusing the same tie fixture the buggy code happened to satisfy, rather than a genuine LF-majority case. Root cause: an Edit attempted earlier in this phase to fix this exact arithmetic error was blocked by the tier guard, and a later dispatch was incorrectly told it had already landed. Fixed: condition is now crlfCount >= bareLfCount; the test fixture corrected to a genuine 2:1 majority, with a new explicit tie-case test. MAJOR — phase.cts's cmdPhaseInsert built an EOL-aware bulletEntry via detectEol(rawContent), justified by a comment claiming a hardcoded '\n' corrupts a CRLF ROADMAP.md. False: this write goes through platformWriteSync, whose normalizeContent/_normalizeMd unconditionally converts CRLF->LF for any .md target — the templating was inert dead code, erased before the file is ever written. Reverted to hardcoded '\n', comment corrected to state the true reasoning. The separate [^\n]* -> [^\r\n]* widening one function up (a real splice-position fix, independent of final EOL) was kept. MAJOR — roadmap-upgrade.cts's stated rationale for switching onto splitLines/joinLines was wrong (both functions always agreed on line representation, before and after — the claimed equality-check risk never existed), and the change it justified introduced a real regression: forcing every line onto one dominant terminator silently rewrites untouched lines' EOL on a mixed-CRLF/LF ROADMAP.md. This write path uses raw fs.writeFileSync, not platformWriteSync, so unlike the phase.cts case above the regression is genuinely live. Fixing this took two attempts. The first attempt (revert to split('\n')/join('\n') plus a suppression comment) was correctly blocked by an agent that discovered local/no-crlf-fragile-split is a PROTECTED_RULES entry in tests/portability-rule-disable-ban.test.cjs — a hard, out-of-band, ADR-1703-governed guardrail banning any eslint-disable of this rule anywhere in src/**/*.cts. That agent also detected and correctly disregarded an injected instruction that appeared in tool output during a git operation, per this session's untrusted-content policy. The actual fix: computeMigrationPlan reverted to roadmapContent.split('\n') (confirmed lint-clean — the rule's data-flow tracking only follows a variable's initializer, and this one is declared empty then reassigned in a try block). applyMigration's write-back now splices edits against the ORIGINAL content string via indexOf('\n', pos) boundary-walking instead of a full split/rejoin, so every untouched character — including every line's own terminator — is copied byte-for-byte. A capture-group split (/(\r\n|\n)/, preserving terminators inline) was tried first and empirically confirmed to still trip the rule before this approach was chosen instead. MINOR (security) — roadmap.cts's cmdRoadmapAnnotateDependencies used the STRING form of String#replace, so $&, $`, $', $1-$9 inside must_haves.truths content (author-controlled) were interpreted as replacement directives, splicing unrelated ROADMAP.md text into the result. Fixed with the function-replacement form, which is never pattern-interpreted. Verified before/after with the reviewer's exact repro. Also disclosed rather than silently left: test matrix row 31 (four planned CRLF-materialized regression tests) was never implemented as separate files — corrected to record the actual verification (a manual --check run plus incidental existing coverage via each script's normalizeLineEndings: normalizeEol alias). parseMustHavesBlock's LF behavior was claimed byte-for-byte unchanged but the old yaml.indexOf(blockMatch[0]) substring search could match an unrelated earlier occurrence of the header text (e.g. inside a quoted value) — the split-then-scan fix incidentally also closes this, a strict improvement now recorded in the design doc rather than left implicit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3413): checkpoint 2 red — missing eslint ignore entry, RuleTester config error Checkpoint 2 came back red with 5 failures on the reviewed sha, both gaps genuinely undetectable by any local gate. eslint.config.mjs was missing the 'gsd-core/bin/lib/text-lines.cjs' ignores-list entry (ADR-457: generated .cjs artifacts are excluded from direct type-aware linting). Phase 1's sibling entry (pattern.cjs) sits two lines above it and was the exact precedent read while researching the six-gate ripple for this module -- missed anyway. Caught by tests/repo-invariants.test.cjs's bin/lib coverage-tracking test, which only runs on the remote suite. tests/no-crlf-fragile-split.rule.test.cjs's row-32 case specified both `messageId` and `message` on the same RuleTester error assertion -- ESLint's RuleTester rejects that combination outright. This existed since the test was first authored and was never caught locally: `npx eslint` only lints the file's syntax, it does not execute RuleTester, and local `node --test` is hard-blocked in this repo -- the assertion had never actually RUN before this checkpoint. It was even present in checkpoint 1's failure list, listed there as one of the "expected RED" tests; I matched it against my expected-failures list by test NAME only and never inspected the actual failure detail closely enough to notice it was failing for the wrong reason (a RuleTester config error, not the intended message-text mismatch). Fixed by keeping `message` (the exact-text assertion the test exists to make) and dropping `messageId`. Verified the crlfFragileSplit message string in eslint-rules/no-crlf-fragile-split.cjs matches this assertion character-for-character, and swept every other invalid case in the file for the same double-specification bug (none found -- all pre-existing cases use messageId alone). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(#3413): add Fixed changeset for the #3360 CRLF parsing fix The sole user-visible effect of this phase. No breaking-change label or Changed fragment needed — ADR-3212's Backward Compatibility section names the Node floor (Phase 1, already shipped) as the epic's only breaking change; Phase 2 has none. * chore(#3413): backfill changeset pr number to 3420 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1000 lines
34 KiB
TypeScript
1000 lines
34 KiB
TypeScript
/**
|
|
* Open Artifact Audit — Cross-type unresolved state scanner
|
|
*
|
|
* Scans all .planning/ artifact categories for items with open/unresolved state.
|
|
* Returns structured JSON for workflow consumption.
|
|
* Called by: gsd-tools.cjs audit-open
|
|
* Used by: /gsd:complete-milestone pre-close gate
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/audit.cjs collapsed
|
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
* from the prior hand-written .cjs; only strict types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
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 planningWorkspace = require('./planning-workspace.cjs');
|
|
const { planningDir } = planningWorkspace;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import frontmatter = require('./frontmatter.cjs');
|
|
const { extractFrontmatter } = frontmatter;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import phaseIdMod = require('./phase-id.cjs');
|
|
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
|
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface DebugSessionItem {
|
|
slug: string;
|
|
status: string;
|
|
updated: string;
|
|
hypothesis: string;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface QuickTaskItem {
|
|
slug: string;
|
|
date: string;
|
|
status: string;
|
|
description: string;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface ThreadItem {
|
|
slug: string;
|
|
status: string;
|
|
updated: string;
|
|
title: string;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface TodoItem {
|
|
filename: string;
|
|
priority: string;
|
|
area: string;
|
|
summary: string;
|
|
scan_error?: boolean;
|
|
_remainder_count?: number;
|
|
}
|
|
|
|
interface SeedItem {
|
|
seed_id: string;
|
|
slug: string;
|
|
status: string;
|
|
title: string;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface UatGapItem {
|
|
phase: string;
|
|
file: string;
|
|
status: string;
|
|
open_scenario_count: number;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface VerificationGapItem {
|
|
phase: string;
|
|
file: string;
|
|
status: string;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface ContextQuestionItem {
|
|
phase: string;
|
|
file: string;
|
|
question_count: number;
|
|
questions: string[];
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
interface DeferredItem {
|
|
phase: string;
|
|
file: string;
|
|
text: string;
|
|
scan_error?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Minimal structural view of `uat.cjs` — only the export `scanDeferredItems`
|
|
* lazily requires. Mirrors the local-interface convention in
|
|
* `audit-command-router.cts`, which types its lazy requires the same way.
|
|
*/
|
|
interface UatDeferredModule {
|
|
parseDeferredItems(content: string): Array<{ name: string }>;
|
|
}
|
|
|
|
interface AuditCounts {
|
|
debug_sessions: number;
|
|
quick_tasks: number;
|
|
threads: number;
|
|
todos: number;
|
|
seeds: number;
|
|
uat_gaps: number;
|
|
verification_gaps: number;
|
|
context_questions: number;
|
|
deferred_items: number;
|
|
total: number;
|
|
}
|
|
|
|
interface AuditResult {
|
|
scanned_at: string;
|
|
has_open_items: boolean;
|
|
counts: AuditCounts;
|
|
items: {
|
|
debug_sessions: DebugSessionItem[];
|
|
quick_tasks: QuickTaskItem[];
|
|
threads: ThreadItem[];
|
|
todos: TodoItem[];
|
|
seeds: SeedItem[];
|
|
uat_gaps: UatGapItem[];
|
|
verification_gaps: VerificationGapItem[];
|
|
context_questions: ContextQuestionItem[];
|
|
deferred_items: DeferredItem[];
|
|
};
|
|
}
|
|
|
|
// The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
|
|
// verbatim with the #2287 phase-boundary reader in `uat.cts`.
|
|
const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
|
|
|
|
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
|
|
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
|
|
// not recreated on each loop iteration.
|
|
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
|
|
|
|
// ─── scanDebugSessions ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/debug/ for open sessions.
|
|
* Open = status NOT in ['resolved', 'complete'].
|
|
* Ignores the resolved/ subdirectory.
|
|
*/
|
|
function scanDebugSessions(planDir: string): DebugSessionItem[] {
|
|
const debugDir = path.join(planDir, 'debug');
|
|
if (!fs.existsSync(debugDir)) return [];
|
|
|
|
const results: DebugSessionItem[] = [];
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(debugDir, { withFileTypes: true });
|
|
} catch {
|
|
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
|
}
|
|
|
|
for (const entry of files) {
|
|
if (!entry.isFile()) continue;
|
|
if (!entry.name.endsWith('.md')) continue;
|
|
|
|
const filePath = path.join(debugDir, entry.name);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
if (status === 'resolved' || status === 'complete') continue;
|
|
|
|
// Extract hypothesis from "Current Focus" block if parseable
|
|
let hypothesis = '';
|
|
const focusSection = collectSection(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true });
|
|
if (focusSection) {
|
|
const focusText = focusSection.body.trim().split('\n')[0].trim();
|
|
hypothesis = sanitizeForDisplay(focusText.slice(0, 100));
|
|
}
|
|
|
|
const slug = path.basename(entry.name, '.md');
|
|
results.push({
|
|
slug: sanitizeForDisplay(slug),
|
|
status: sanitizeForDisplay(status),
|
|
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
|
hypothesis,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/quick/ for incomplete tasks.
|
|
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
|
*/
|
|
function scanQuickTasks(planDir: string): QuickTaskItem[] {
|
|
const quickDir = path.join(planDir, 'quick');
|
|
if (!fs.existsSync(quickDir)) return [];
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(quickDir, { withFileTypes: true });
|
|
} catch {
|
|
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
|
}
|
|
|
|
const results: QuickTaskItem[] = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const dirName = entry.name;
|
|
const taskDir = path.join(quickDir, dirName);
|
|
|
|
let safeTaskDir: string;
|
|
try {
|
|
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
|
|
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
|
|
//
|
|
// #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
|
|
// scanPhasePlans migration): this scans a quick task's OWN directory
|
|
// (`.planning/quick/<task>/`) for THAT task's single completion record —
|
|
// "does this one quick task have a SUMMARY.md" — not a phase directory's
|
|
// live-plan/summary counting question. scanPhasePlans is the wrong tool
|
|
// here; there is no plan/summary PAIRING to derive, only a single
|
|
// filename presence check local to a non-phase directory.
|
|
let summaryPath: string | null = null;
|
|
try {
|
|
const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true })
|
|
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
|
|
if (summaryFiles.length > 0) {
|
|
// Prefer the per-task `${quick_id}-SUMMARY.md` form when present.
|
|
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|
|
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|
|
|| summaryFiles[0];
|
|
summaryPath = path.join(safeTaskDir, preferred.name);
|
|
}
|
|
} catch {
|
|
// fall through with summaryPath = null → status: missing
|
|
}
|
|
|
|
let status = 'missing';
|
|
const description = '';
|
|
|
|
if (summaryPath && fs.existsSync(summaryPath)) {
|
|
let safeSum: string;
|
|
try {
|
|
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
const content = platformReadSync(safeSum);
|
|
if (content === null) {
|
|
status = 'unreadable';
|
|
} else {
|
|
const fm = extractFrontmatter(content, safeSum);
|
|
status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
}
|
|
}
|
|
|
|
if (status === 'complete') continue;
|
|
|
|
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
|
|
let date = '';
|
|
let slug = sanitizeForDisplay(dirName);
|
|
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
|
|
if (dateMatch) {
|
|
date = dateMatch[1];
|
|
slug = sanitizeForDisplay(dateMatch[2]);
|
|
}
|
|
|
|
results.push({
|
|
slug,
|
|
date,
|
|
status: sanitizeForDisplay(status),
|
|
description,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanThreads ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/threads/ for open threads.
|
|
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
|
|
*/
|
|
function scanThreads(planDir: string): ThreadItem[] {
|
|
const threadsDir = path.join(planDir, 'threads');
|
|
if (!fs.existsSync(threadsDir)) return [];
|
|
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(threadsDir, { withFileTypes: true });
|
|
} catch {
|
|
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
|
}
|
|
|
|
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
|
|
const results: ThreadItem[] = [];
|
|
|
|
for (const entry of files) {
|
|
if (!entry.isFile()) continue;
|
|
if (!entry.name.endsWith('.md')) continue;
|
|
|
|
const filePath = path.join(threadsDir, entry.name);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
let status = ((fm.status as string) || '').toLowerCase().trim();
|
|
|
|
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
|
|
if (!status) {
|
|
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
|
|
if (bodyStatusMatch) {
|
|
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
|
|
}
|
|
}
|
|
|
|
if (!openStatuses.has(status)) continue;
|
|
|
|
// Extract title from # Thread: heading or frontmatter title
|
|
let title = sanitizeForDisplay(fm.title || '');
|
|
if (!title) {
|
|
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
|
|
if (headingMatch) {
|
|
title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
|
}
|
|
}
|
|
|
|
const slug = path.basename(entry.name, '.md');
|
|
results.push({
|
|
slug: sanitizeForDisplay(slug),
|
|
status: sanitizeForDisplay(status),
|
|
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
|
title,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanTodos ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/todos/pending/ for pending todos.
|
|
* Returns array of { filename, priority, area, summary }.
|
|
* Display limited to first 5 + count of remainder.
|
|
*/
|
|
function scanTodos(planDir: string): TodoItem[] {
|
|
const pendingDir = path.join(planDir, 'todos', 'pending');
|
|
if (!fs.existsSync(pendingDir)) return [];
|
|
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(pendingDir, { withFileTypes: true });
|
|
} catch {
|
|
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
|
}
|
|
|
|
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
const results: TodoItem[] = [];
|
|
|
|
const displayFiles = mdFiles.slice(0, 5);
|
|
for (const entry of displayFiles) {
|
|
const filePath = path.join(pendingDir, entry.name);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
|
|
// Extract first line of body after frontmatter
|
|
const bodyMatch = content.replace(/^---[\s\S]*?---\r?\n?/, '');
|
|
const firstLine = splitLines(bodyMatch.trim())[0] || '';
|
|
const summary = sanitizeForDisplay(firstLine.slice(0, 100));
|
|
|
|
results.push({
|
|
filename: sanitizeForDisplay(entry.name),
|
|
priority: sanitizeForDisplay(fm.priority || ''),
|
|
area: sanitizeForDisplay(fm.area || ''),
|
|
summary,
|
|
});
|
|
}
|
|
|
|
if (mdFiles.length > 5) {
|
|
results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/seeds/SEED-*.md for unimplemented seeds.
|
|
* Unimplemented if status in ['dormant', 'active', 'triggered'].
|
|
*/
|
|
function scanSeeds(planDir: string): SeedItem[] {
|
|
const seedsDir = path.join(planDir, 'seeds');
|
|
if (!fs.existsSync(seedsDir)) return [];
|
|
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(seedsDir, { withFileTypes: true });
|
|
} catch {
|
|
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
|
}
|
|
|
|
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
|
|
const results: SeedItem[] = [];
|
|
|
|
for (const entry of files) {
|
|
if (!entry.isFile()) continue;
|
|
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
|
|
|
|
const filePath = path.join(seedsDir, entry.name);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'dormant').toLowerCase();
|
|
|
|
if (!unimplementedStatuses.has(status)) continue;
|
|
|
|
// Extract seed_id from filename or frontmatter
|
|
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
|
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
|
|
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
|
|
|
|
let title = sanitizeForDisplay(fm.title || '');
|
|
if (!title) {
|
|
const headingMatch = content.match(/^#\s*(.+)$/m);
|
|
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
|
|
}
|
|
|
|
results.push({
|
|
seed_id: sanitizeForDisplay(seed_id),
|
|
slug,
|
|
status: sanitizeForDisplay(status),
|
|
title,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
|
|
*/
|
|
function scanUatGaps(planDir: string): UatGapItem[] {
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
if (!fs.existsSync(phasesDir)) return [];
|
|
|
|
let dirs: string[];
|
|
try {
|
|
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort();
|
|
} catch {
|
|
return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
|
|
}
|
|
|
|
const results: UatGapItem[] = [];
|
|
|
|
for (const dir of dirs) {
|
|
const phaseDir = path.join(phasesDir, dir);
|
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(phaseDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
|
|
const filePath = path.join(phaseDir, file);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
const result = ((fm.result as string) || '').toLowerCase();
|
|
|
|
// Also accept `result: all_pass` as a fallback when status is absent
|
|
// — covers UATs that omit `status:`.
|
|
if (TERMINAL_UAT_STATUSES.has(status)) continue;
|
|
if (status === 'unknown' && result === 'all_pass') continue;
|
|
|
|
// Count open scenarios
|
|
const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
|
|
|
|
results.push({
|
|
phase: sanitizeForDisplay(phaseNum),
|
|
file: sanitizeForDisplay(file),
|
|
status: sanitizeForDisplay(status),
|
|
open_scenario_count: pendingMatches,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/phases for VERIFICATION gaps.
|
|
*/
|
|
function scanVerificationGaps(planDir: string): VerificationGapItem[] {
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
if (!fs.existsSync(phasesDir)) return [];
|
|
|
|
let dirs: string[];
|
|
try {
|
|
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort();
|
|
} catch {
|
|
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
|
}
|
|
|
|
const results: VerificationGapItem[] = [];
|
|
|
|
for (const dir of dirs) {
|
|
const phaseDir = path.join(phasesDir, dir);
|
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(phaseDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
|
|
const filePath = path.join(phaseDir, file);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
|
|
if (status !== 'gaps_found' && status !== 'human_needed') continue;
|
|
|
|
results.push({
|
|
phase: sanitizeForDisplay(phaseNum),
|
|
file: sanitizeForDisplay(file),
|
|
status: sanitizeForDisplay(status),
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanContextQuestions ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/phases for CONTEXT files with open_questions.
|
|
*/
|
|
function scanContextQuestions(planDir: string): ContextQuestionItem[] {
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
if (!fs.existsSync(phasesDir)) return [];
|
|
|
|
let dirs: string[];
|
|
try {
|
|
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort();
|
|
} catch {
|
|
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
|
}
|
|
|
|
const results: ContextQuestionItem[] = [];
|
|
|
|
for (const dir of dirs) {
|
|
const phaseDir = path.join(phasesDir, dir);
|
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(phaseDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
|
|
const filePath = path.join(phaseDir, file);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
|
|
// Check frontmatter open_questions field
|
|
let questions: string[] = [];
|
|
if (fm.open_questions) {
|
|
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
|
|
questions = (fm.open_questions as unknown[]).map(q => sanitizeForDisplay(String(q).slice(0, 200)));
|
|
}
|
|
}
|
|
|
|
// Also check for ## Open Questions section in body
|
|
if (questions.length === 0) {
|
|
const oqSection = collectSection(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
|
|
if (oqSection) {
|
|
const oqBody = oqSection.body.trim();
|
|
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
|
|
const items = oqBody.split('\n')
|
|
.map((l: string) => l.trim())
|
|
.filter((l: string) => l && l !== '-' && l !== '*')
|
|
.filter((l: string) => /^[-*\d]/.test(l) || l.includes('?'));
|
|
questions = items.slice(0, 3).map((q: string) => sanitizeForDisplay(q.slice(0, 200)));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (questions.length === 0) continue;
|
|
|
|
results.push({
|
|
phase: sanitizeForDisplay(phaseNum),
|
|
file: sanitizeForDisplay(file),
|
|
question_count: questions.length,
|
|
questions: questions.slice(0, 3),
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── scanDeferredItems ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646).
|
|
*
|
|
* The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
|
|
* log an out-of-scope discovery here rather than fix it. #2287 made that file
|
|
* readable at the PHASE boundary (`/gsd-progress` check 7, `audit-uat`); this
|
|
* scanner closes the remaining reader gap one boundary up, so an entry still
|
|
* unresolved at MILESTONE close surfaces in the pre-close audit alongside the
|
|
* other eight categories and the existing `[R]/[A]/[C]` prompt applies to it.
|
|
* Without this, phase directories archive to `milestones/vX.Y-phases/` (#1871)
|
|
* and the entry leaves the live tree having never been triaged.
|
|
*
|
|
* The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
|
|
* already exports `parseDeferredItems`, which owns the parsing rule (entries
|
|
* under a `## Deferred Items` level-2 heading, else the whole file fail-safe;
|
|
* RESOLVED only on an explicit case-insensitive `status: resolved` field).
|
|
* Duplicating that inequality is how two readers of the same file drift into
|
|
* disagreeing about what "open" means. The require is deliberately LAZY,
|
|
* inside the scan, to preserve `audit-command-router.cts`'s property that a
|
|
* route never loads the module it does not need.
|
|
*/
|
|
function scanDeferredItems(planDir: string): DeferredItem[] {
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
if (!fs.existsSync(phasesDir)) return [];
|
|
|
|
let dirs: string[];
|
|
try {
|
|
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort();
|
|
} catch {
|
|
return [{ scan_error: true, phase: '', file: '', text: '' }];
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const uat: UatDeferredModule = require('./uat.cjs');
|
|
|
|
const results: DeferredItem[] = [];
|
|
|
|
for (const dir of dirs) {
|
|
const phaseDir = path.join(phasesDir, dir);
|
|
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
|
|
|
|
const filePath = path.join(phaseDir, DEFERRED_ITEMS_FILENAME);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'deferred items file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const content = platformReadSync(safeFilePath);
|
|
if (content === null) continue;
|
|
|
|
for (const item of uat.parseDeferredItems(content)) {
|
|
results.push({
|
|
phase: sanitizeForDisplay(phaseNum),
|
|
file: DEFERRED_ITEMS_FILENAME,
|
|
text: sanitizeForDisplay(item.name),
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Main audit function. Scans all .planning/ artifact categories.
|
|
*
|
|
* @param cwd - Project root directory
|
|
* @returns Structured audit result
|
|
*/
|
|
function auditOpenArtifacts(cwd: string): AuditResult {
|
|
const planDir = planningDir(cwd);
|
|
|
|
const debugSessions = (() => {
|
|
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; }
|
|
})();
|
|
|
|
const quickTasks = (() => {
|
|
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; }
|
|
})();
|
|
|
|
const threads = (() => {
|
|
try { return scanThreads(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; }
|
|
})();
|
|
|
|
const todos = (() => {
|
|
try { return scanTodos(planDir); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; }
|
|
})();
|
|
|
|
const seeds = (() => {
|
|
try { return scanSeeds(planDir); } catch { return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }]; }
|
|
})();
|
|
|
|
const uatGaps = (() => {
|
|
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }]; }
|
|
})();
|
|
|
|
const verificationGaps = (() => {
|
|
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; }
|
|
})();
|
|
|
|
const contextQuestions = (() => {
|
|
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }]; }
|
|
})();
|
|
|
|
const deferredItems = (() => {
|
|
try { return scanDeferredItems(planDir); } catch { return [{ scan_error: true, phase: '', file: '', text: '' }]; }
|
|
})();
|
|
|
|
// Count real items (not scan_error sentinels)
|
|
const countReal = (arr: Array<{ scan_error?: boolean; _remainder_count?: number }>) =>
|
|
arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
|
|
|
const counts: AuditCounts = {
|
|
debug_sessions: countReal(debugSessions),
|
|
quick_tasks: countReal(quickTasks),
|
|
threads: countReal(threads),
|
|
todos: countReal(todos),
|
|
seeds: countReal(seeds),
|
|
uat_gaps: countReal(uatGaps),
|
|
verification_gaps: countReal(verificationGaps),
|
|
context_questions: countReal(contextQuestions),
|
|
deferred_items: countReal(deferredItems),
|
|
total: 0,
|
|
};
|
|
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions + counts.deferred_items;
|
|
|
|
return {
|
|
scanned_at: new Date().toISOString(),
|
|
has_open_items: counts.total > 0,
|
|
counts,
|
|
items: {
|
|
debug_sessions: debugSessions,
|
|
quick_tasks: quickTasks,
|
|
threads,
|
|
todos,
|
|
seeds,
|
|
uat_gaps: uatGaps,
|
|
verification_gaps: verificationGaps,
|
|
context_questions: contextQuestions,
|
|
deferred_items: deferredItems,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── formatAuditReport ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format the audit result as a human-readable report.
|
|
*
|
|
* @param auditResult - Result from auditOpenArtifacts()
|
|
* @returns Formatted report
|
|
*/
|
|
function formatAuditReport(auditResult: AuditResult): string {
|
|
const { counts, items, has_open_items } = auditResult;
|
|
const lines: string[] = [];
|
|
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
|
|
|
|
lines.push(hr);
|
|
lines.push(' Milestone Close: Open Artifact Audit');
|
|
lines.push(hr);
|
|
|
|
if (!has_open_items) {
|
|
lines.push('');
|
|
lines.push(' All artifact types clear. Safe to proceed.');
|
|
lines.push('');
|
|
lines.push(hr);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// Debug sessions (blocking quality — red)
|
|
if (counts.debug_sessions > 0) {
|
|
lines.push('');
|
|
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
|
|
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
|
|
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
|
|
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
|
|
}
|
|
}
|
|
|
|
// UAT gaps (blocking quality — red)
|
|
if (counts.uat_gaps > 0) {
|
|
lines.push('');
|
|
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
|
|
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
|
|
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
|
|
}
|
|
}
|
|
|
|
// Verification gaps (blocking quality — red)
|
|
if (counts.verification_gaps > 0) {
|
|
lines.push('');
|
|
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
|
|
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
|
|
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
|
|
}
|
|
}
|
|
|
|
// Quick tasks (incomplete work — yellow)
|
|
if (counts.quick_tasks > 0) {
|
|
lines.push('');
|
|
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
|
|
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
|
|
const d = item.date ? ` (${item.date})` : '';
|
|
lines.push(` • ${item.slug}${d} [${item.status}]`);
|
|
}
|
|
}
|
|
|
|
// Todos (incomplete work — yellow)
|
|
if (counts.todos > 0) {
|
|
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
|
|
const remainder = items.todos.find(i => i._remainder_count);
|
|
lines.push('');
|
|
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
|
|
for (const item of realTodos) {
|
|
const area = item.area ? ` [${item.area}]` : '';
|
|
const pri = item.priority ? ` (${item.priority})` : '';
|
|
lines.push(` • ${item.filename}${area}${pri}`);
|
|
if (item.summary) lines.push(` ${item.summary}`);
|
|
}
|
|
if (remainder) {
|
|
lines.push(` ... and ${remainder._remainder_count} more`);
|
|
}
|
|
}
|
|
|
|
// Threads (deferred decisions — blue)
|
|
if (counts.threads > 0) {
|
|
lines.push('');
|
|
lines.push(`🔵 Open Threads (${counts.threads} active)`);
|
|
for (const item of items.threads.filter(i => !i.scan_error)) {
|
|
const title = item.title ? ` — ${item.title}` : '';
|
|
lines.push(` • ${item.slug} [${item.status}]${title}`);
|
|
}
|
|
}
|
|
|
|
// Seeds (deferred decisions — blue)
|
|
if (counts.seeds > 0) {
|
|
lines.push('');
|
|
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
|
|
for (const item of items.seeds.filter(i => !i.scan_error)) {
|
|
const title = item.title ? ` — ${item.title}` : '';
|
|
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
|
|
}
|
|
}
|
|
|
|
// Context questions (deferred decisions — blue)
|
|
if (counts.context_questions > 0) {
|
|
lines.push('');
|
|
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
|
|
for (const item of items.context_questions.filter(i => !i.scan_error)) {
|
|
lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
|
|
for (const q of item.questions) {
|
|
lines.push(` - ${q}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deferred items (deferred decisions — blue). Out-of-scope discoveries a
|
|
// phase agent recorded rather than fixed, still unresolved at close (#2646).
|
|
if (counts.deferred_items > 0) {
|
|
lines.push('');
|
|
lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
|
|
for (const item of items.deferred_items.filter(i => !i.scan_error)) {
|
|
lines.push(` • Phase ${item.phase}: ${item.text}`);
|
|
}
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push(hr);
|
|
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
|
|
lines.push(hr);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export = { auditOpenArtifacts, formatAuditReport };
|