diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index a0fcf3228..b31115144 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -137,7 +137,7 @@ function main(): number { const gateScan = { tagged: gateTagged, head }; if (has('--json')) { const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; - process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); + process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, fix_range: fixRange ?? null, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly, consumed: collected.consumed }) + '\n'); return EXIT.ok; } const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`]; diff --git a/bin/gstack-state-matrix b/bin/gstack-state-matrix index 6e7ac0204..f17ee2cd0 100755 --- a/bin/gstack-state-matrix +++ b/bin/gstack-state-matrix @@ -5,6 +5,7 @@ * gstack-state-matrix detect [--base ] [--diff-base ] * gstack-state-matrix check [--repo ] [--expect ] [--json] * gstack-state-matrix outfile + * gstack-state-matrix findings * * `detect` prints `SCOPE_STATE_MACHINE=true|false` (shell-safe for * `source <(...)`, with `# state-machine:` comment lines naming the hits) from @@ -14,7 +15,10 @@ * last; exit 0 ok, 1 incomplete (STATE_MATRIX_INCOMPLETE). `outfile` creates and * prints a new empty 0600 file in the repo's private `.gstack/tmp/` (0700, kept * out of git through info/exclude) for the specialist's output, so a later - * review never checks or commits an earlier run's file. Logic: lib/state-matrix.ts. + * review never checks or commits an earlier run's file. `findings` prints the + * output for Step 9.3's merge with each untested cell mapped below the floor + * (lib/review-scope.ts mapStateMatrixOutput) and `STATE_MATRIX_FINDINGS:` on + * stderr. Logic: lib/state-matrix.ts. */ import { spawnSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; @@ -24,6 +28,7 @@ import { addedLines } from '../lib/banned-terms'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { resultLine } from '../lib/result-codes'; import { resolveBaseBranch } from '../lib/ship-policy'; +import { mapStateMatrixOutput } from '../lib/review-scope'; import { checkStateMatrix, detectStateMachine, renderStateMatrixCheck, type Expectation } from '../lib/state-matrix'; const HELP = `gstack-state-matrix — kind × owner action × resulting row for proposal, hold and approval state machines @@ -32,6 +37,7 @@ const HELP = `gstack-state-matrix — kind × owner action × resulting row for check [--repo ] [--expect ] [--json] gaps in the state-machines specialist's output (review/specialists/state-machines.md) outfile print a new empty private file for that output + findings that output for the Step 9.3 merge, each untested cell mapped below the floor ${exitTableHelp()}`; @@ -71,6 +77,15 @@ function check(rest: string[]): number { return EXIT.ok; } +function findings(rest: string[]): number { + const input = rest.find(a => !a.startsWith('--')); + if (!input) { process.stderr.write(`gstack-state-matrix findings: a reviewer output file (or -) is required\n${HELP}\n`); return EXIT.usage; } + const r = mapStateMatrixOutput(input === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(input, 'utf8')); + process.stdout.write(r.text.endsWith('\n') ? r.text : `${r.text}\n`); + process.stderr.write(`STATE_MATRIX_FINDINGS: mapped=${r.mapped} (untested cells below the floor; docs/review-policy.md test gap)\n`); + return EXIT.ok; +} + function outfile(): number { const root = git(['rev-parse', '--show-toplevel']); const exclude = git(['rev-parse', '--git-path', 'info/exclude']); @@ -94,6 +109,7 @@ function main(argv: string[]): number { if (cmd === 'detect') return detect(rest); if (cmd === 'check') return check(rest); if (cmd === 'outfile') return outfile(); + if (cmd === 'findings') return findings(rest); process.stderr.write(`gstack-state-matrix: unknown command ${cmd}\n${HELP}\n`); return EXIT.usage; } diff --git a/docs/review-policy.md b/docs/review-policy.md index 032d87fea..31f5d3f25 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -58,7 +58,7 @@ whatever class it resembles. | **bounded repeat work** | A re-dispatch on every call, a redundant screen after main moves, an orphaned scratch branch, a refusal that could come one step earlier in the sequence, a settle that could wait one more check. | The operation completes, nothing is lost, and the cost is one more bounded run; uncapped spend is the sideways sweep's CRITICAL, not this class. | | **conservative refusal** | Stale state scoped wider than its cause: a RED verdict that keeps refusing a head after main moved, a pending result that keeps refusing after it settled. | A refusal costs a rerun and never passes something wrong; a wrong pass would be the floor. | | **artifact presentation** | A string from an artifact printed verbatim into a parsed line (whitespace, control characters); a classifier that reads a non-zero exit code beside a non-RED verdict as RED. | Display and classification of the repository's own output; it forges no parsed field. | -| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. | +| **test gap** | A state-matrix cell without a test, a coverage percentage, a weak or flaky test, a fixture that shares the coordinator's environment. | Category `testing`, below the floor by definition above; a gap is work for the test audit, not a defect in the diff. Precedence for state machines: the specialist still reports each untested cell CRITICAL so `gstack-state-matrix check` proves the matrix complete, and `gstack-state-matrix findings` maps it to INFORMATIONAL per matrix cell before the merge; a wrong row or collapsed kinds stays CRITICAL, and a cell with a second finding keeps both. | A reviewer that wants a class promoted argues it against the floor with a reaching input, in the finding, and the owner rules; the table records the @@ -168,6 +168,22 @@ or test path, CI workflows, credential, state-root and the paths below). commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once. +**Data files no code names are docs.** A changed or deleted JSON, JSONL, NDJSON, CSV, +TSV or raster image (PNG, JPEG, GIF, WebP, BMP, ICO, AVIF) counts as docs when no +non-docs file names its basename at the head, at the merge base or, for live /ship, in +the working tree with untracked files; a data file named only by another data file +follows that file, four files deep. Precedence: agent prose (`.gstack/**`), a test or +fixture path, a gate path, a config file (`.github/**`, anything under a `config/` +directory, dotfiles, `*.config.*`, `package.json`, lockfiles, `tsconfig*.json`, +`manifest.json` and the other tool manifests `lib/review-depth.ts` lists) and a named +consumer each keep the path code, and YAML, TOML and SVG are always code. More than +2,000 data paths keep every one code; a `git grep` failure is `consumer_scan_failed` +and fails the depth closed. Only `classifyDiff` applies the rule (`lib/review-collect.ts` +finds the consumers, `gstack-ship-preflight`'s `RUNTIME_LINES` reads the same +function); every path-only caller (the receipt's gate-scan decision, the Step 16 +post-review route, /autoplan's docs-only scope) keeps data files code. A numeric claim +in a data file still schedules the outside voice. + | Depth | When | Pass 1 dispatches | |---|---|---| | **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers | @@ -248,6 +264,9 @@ the audits and watchers at the host default too prompt. At runtime 0 light runs no adversarial pass either. - Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy diff with few runtime lines dispatches fewer specialists. +- A JSON, CSV, TSV or raster data file no code names is docs, not runtime (GSTA-167 + part 4a). A binary image no code names is no longer a `binary_code_path` error + that forces full depth; one that code names still is. ## What the receipt records diff --git a/lib/review-collect.ts b/lib/review-collect.ts index 3846df0f3..966dfb485 100644 --- a/lib/review-collect.ts +++ b/lib/review-collect.ts @@ -12,7 +12,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { GENERATED_PATH, parseRegistry } from './regen'; import { RESULT_CODES } from './result-codes'; -import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth'; +import { classifyPath, dataCandidate, type ClassInput, type DepthError, type PathLines, type RegistryInfo } from './review-depth'; export interface Collected extends ClassInput { perPath: PathLines[]; @@ -25,11 +25,12 @@ export interface Collected extends ClassInput { diffLines: number; mergeBase: string | null; untracked: string[]; + consumed: string[] | 'all'; } -function git(root: string, args: string[]): { ok: boolean; out: string; err: string } { +function git(root: string, args: string[]): { ok: boolean; status: number | null; out: string; err: string } { const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); - return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; + return { ok: r.status === 0, status: r.status, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; } const scratch = (p: string) => p.startsWith('.gstack/tmp/'); const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200); @@ -88,7 +89,58 @@ function readDiff(root: string, range: string[], errors: DepthError[]): Pick, extra: Pick): Collected { +/** More data candidates than this keep every data file code: the consumer scan is not run. */ +export const CONSUMER_SCAN_CAP = 2000; +/** How many data files deep a chain of data naming data is followed (a manifest naming a data file) before the rest count as consumed. */ +export const CONSUMER_SCAN_DEPTH = 4; + +/** + * The data candidates (lib/review-depth.ts dataCandidate) among the changed and deleted paths that a non-docs file names + * by basename, at each rev and, with `live`, in the working tree with untracked files. The merge base is one of the revs, + * so a consumer the change removes still counts. A data file named only by another data file is consumed when that one + * is, followed CONSUMER_SCAN_DEPTH files deep; anything still open after that counts as consumed. A git grep failure is + * a collector error, which fails the depth closed. + */ +export function scanConsumers(root: string, paths: readonly string[], revs: readonly string[], live: boolean, classRoot: string | readonly string[], errors: DepthError[]): string[] | 'all' { + const candidates = [...new Set(paths)].filter(p => dataCandidate(p, classRoot)).sort(); + if (candidates.length > CONSUMER_SCAN_CAP) return 'all'; + const base = (p: string) => p.slice(p.lastIndexOf('/') + 1); + const named = new Map>(); + const direct = new Set(); + const probed = new Set(); + let probe = candidates; + for (let depth = 0; probe.length && depth < CONSUMER_SCAN_DEPTH; depth++) { + const patterns = [...new Set(probe.map(base))].flatMap(b => ['-e', b]); + const runs = [...revs.map(rev => ({ at: rev, args: [rev, '--'] })), ...(live ? [{ at: '', args: ['--untracked', '--'] }] : [])]; + const next = new Set(); + for (const run of runs) { + const r = git(root, ['grep', '-I', '-F', '-o', '--null', ...patterns, ...run.args]); + if (r.status !== 0 && r.status !== 1) { errors.push({ code: 'consumer_scan_failed', detail: oneLine(r.err || `git grep exited ${r.status}`), input: `git grep -F ${run.at || '--untracked'}`, repair: 'run the git grep by hand and fix what git reports' }); return 'all'; } + for (const line of r.out.split('\n')) { + const [where, match] = line.split('\0'); + if (!where || !match) continue; + const file = run.at ? where.slice(run.at.length + 1) : where; + if (scratch(file) || classifyPath(file, classRoot) === 'docs') continue; + for (const p of probe) { + if (file === p || !match.includes(base(p))) continue; + if (!dataCandidate(file, classRoot)) { direct.add(p); continue; } + (named.get(file) ?? named.set(file, new Set()).get(file)!).add(p); + if (!probed.has(file) && !probe.includes(file)) next.add(file); + } + } + } + for (const p of probe) probed.add(p); + probe = [...next]; + } + const consumed = new Set([...direct, ...probe]); + for (let grew = true; grew;) { + grew = false; + for (const [file, targets] of named) if (consumed.has(file)) for (const p of targets) if (!consumed.has(p)) { consumed.add(p); grew = true; } + } + return candidates.filter(p => consumed.has(p)); +} + +function result(diff: ReturnType, extra: Pick): Collected { const paths = diff.perPath.map(e => e.path).sort(); return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; } @@ -100,13 +152,14 @@ export function collectCommit(root: string, baseSha: string, headSha: string): C const mb = git(root, ['merge-base', baseSha, headSha]); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase, headSha], errors); const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]); const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort(); - return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] }); + const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [headSha, mergeBase], false, skillDirs, errors); + return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [], consumed }); } /** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */ @@ -117,7 +170,7 @@ export function collectLive(root: string, base: string): Collected { const mb = git(root, ['merge-base', ref, 'HEAD']); if (!mb.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); - return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [], consumed: 'all' }); } const mergeBase = mb.out.trim(); const diff = readDiff(root, [mergeBase], errors); @@ -133,5 +186,6 @@ export function collectLive(root: string, base: string): Collected { diff.perPath.push({ path: p, lines, binary }); if (!binary) diff.added[p] = text.split('\n'); } - return result(diff, { root, registry, errors, mergeBase, untracked }); + const consumed = scanConsumers(root, [...diff.perPath.map(e => e.path), ...diff.deleted], [mergeBase], true, root, errors); + return result(diff, { root, registry, errors, mergeBase, untracked, consumed }); } diff --git a/lib/review-depth.ts b/lib/review-depth.ts index bce1fffef..3445396d8 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -48,10 +48,18 @@ const GATE_PATH = [ /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, ]; +/** Data files: JSON, CSV, TSV and raster images. YAML, TOML and SVG are never data here: deploy configs, API specs and executable SVG stay code. */ +export const DATA_PATH = /\.(json|jsonl|ndjson|csv|tsv|png|jpe?g|gif|webp|bmp|ico|avif)$/i; +/** Data-shaped files tools read by convention, with no code naming them: always code. */ +const DATA_CONFIG = [ + /(^|\/)\.github\//, /(^|\/)config\//, /(^|\/)\.[^/]+$/, /(^|\/)[^/]+\.config\.[^/]+$/, + /(^|\/)(package|package-lock|npm-shrinkwrap|composer|deno|jsr|tsconfig[^/]*|jsconfig[^/]*|manifest|app|vercel|netlify|firebase|renovate|turbo|nx|lerna|biome|angular|nest-cli|lint-staged|components|pyrightconfig|cdk|serverless|appsettings[^/]*)\.json$/i, +]; + /** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, SCOPED_RANGE_LINES, - patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), + patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH], DATA_PATH: [DATA_PATH], DATA_CONFIG }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), })).digest('hex').slice(0, 16); /** @@ -70,7 +78,21 @@ export function classifyPath(p: string, root?: string | readonly string[]): Path return root.includes(top) ? 'code' : 'docs'; } -export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path'; +/** + * A code-class path the data rule may move to docs: a data extension, and not agent prose, a gate path or a config file. + * Only classifyDiff applies the rule, and only to a candidate its collector found no consumer for (ClassInput.consumed); + * classifyPath and every path-only caller keep data files code. + */ +export function dataCandidate(p: string, root?: string | readonly string[]): boolean { + return DATA_PATH.test(p) && classifyPath(p, root) === 'code' && ![...AGENT_PROSE, ...GATE_PATH, ...DATA_CONFIG].some(r => r.test(p)); +} + +/** Whether a range needs the gate scan: any path that is not docs by the path-only rule. The data rule never skips it. */ +export function gateScanNeeded(paths: readonly string[], root?: string | readonly string[]): boolean { + return paths.some(p => classifyPath(p, root) !== 'docs'); +} + +export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path' | 'consumer_scan_failed'; /** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */ export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName } export interface PathLines { path: string; lines: number; binary: boolean } @@ -94,12 +116,18 @@ export interface ClassInput { registry?: RegistryInfo; /** Collector failures (a diff git could not list): each fails the depth closed. */ errors?: readonly DepthError[]; + /** + * The data candidates (dataCandidate) a non-docs file names at the head, the merge base or in the working tree + * (lib/review-collect.ts). A candidate not listed counts as docs; 'all' or no scan keeps every data file code. + */ + consumed?: readonly string[] | 'all'; } export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null } const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); -const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root)); +const unconsumedData = (i: ClassInput, p: string) => Array.isArray(i.consumed) && !i.consumed.includes(p) && dataCandidate(p, i.root); +const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) || unconsumedData(i, p) ? 'docs' : classifyPath(p, i.root)); /** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { @@ -109,6 +137,8 @@ export function classifyDiff(i: ClassInput): ClassResult { const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); }; if (!i.paths.length) raise('docs', 'no changed paths'); for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } + const data = i.paths.filter(p => unconsumedData(i, p)); + if (data.length) reasons.push(`docs: ${data.length} data path(s) no code names`); const s = i.scope ?? {}; if (s.SCOPE_ERROR) { raise('code', `scope error: ${s.SCOPE_ERROR}`); diff --git a/lib/review-scope.ts b/lib/review-scope.ts index 97e0e3e19..394815cd9 100644 --- a/lib/review-scope.ts +++ b/lib/review-scope.ts @@ -14,6 +14,7 @@ * Pure functions here; bin/gstack-review-scope runs git and gh. */ import { SPECIALISTS } from './review-depth'; +import { parseSpecialistOutput } from './state-matrix'; export type Locus = 'diff' | 'fix-range' | 'outside'; export type Kind = 'defect' | 'test-gap' | 'advisory'; @@ -133,10 +134,41 @@ function fingerprintLine(fp: unknown): number | undefined { /** The categories a reviewer uses for a test finding; `testing` is what the Testing specialist's schema emits. */ export const TEST_GAP_CATEGORIES: readonly string[] = ['testing', 'tests', 'test-gap', 'test-coverage', 'coverage', 'missing-test', 'untested']; -/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test), or a product defect. */ +/** What mapStateMatrixOutput stamps on an untested state-matrix cell: a test gap, below the floor (docs/review-policy.md). */ +export const UNTESTED_CELL = 'untested-cell'; + +const cellKey = (f: Record): string | null => (f.cell && typeof f.cell === 'object' ? `${String(f.cell.kind ?? '').trim().toLowerCase()}\u0000${String(f.cell.action ?? '').trim().toLowerCase()}` : null); + +/** + * A reviewer output (JSON lines) with each untested state-matrix cell mapped below the floor, per matrix cell and before + * Step 9.3's fingerprint merge: a `state-matrix` finding the checklist's schema writes as "untested cell: …", whose `cell` is a + * cell with `"test": null` in a matrix record of the same output, and the only finding on that cell, becomes INFORMATIONAL with `state_matrix: "untested-cell"` and its + * `raw_severity`. A second finding on the cell (a wrong row, collapsed kinds) keeps both as they are, so a merge can never + * fold a defect into a demoted record; a cell no matrix lists as untested changes nothing. Every other line, matrix records + * included, is returned byte for byte; `gstack-state-matrix check` reads the raw output, never this one. + */ +export function mapStateMatrixOutput(text: string): { text: string; mapped: number } { + const { matrices, findings } = parseSpecialistOutput(text); + const untested = new Set(matrices.flatMap(m => m.cells.filter(c => c.test === null).map(c => cellKey({ cell: c })!))); + const perCell = new Map(); + for (const f of findings) { const k = cellKey(f); if (k) perCell.set(k, (perCell.get(k) ?? 0) + 1); } + let mapped = 0; + const lines = text.split('\n').map(line => { + let f: Record; + try { f = JSON.parse(line.trim()); } catch { return line; } + const k = f && typeof f === 'object' && !f.matrix && f.category === 'state-matrix' && /^untested cell\b/i.test(String(f.summary ?? '')) ? cellKey(f) : null; + if (!k || !untested.has(k) || perCell.get(k) !== 1 || f.state_matrix === UNTESTED_CELL) return line; + mapped += 1; + return JSON.stringify({ ...f, severity: 'INFORMATIONAL', raw_severity: f.severity ?? null, state_matrix: UNTESTED_CELL }); + }); + return { text: lines.join('\n'), mapped }; +} + +/** A finding's kind: optional advice, a test finding (a gap, a weak or flaky test, an untested state-matrix cell still below CRITICAL), or a product defect. */ export function kindOf(finding: ScopeFinding): Kind { if (finding.advisory === true) return 'advisory'; if (TEST_GAP_CATEGORIES.includes(String(finding.category ?? '').toLowerCase())) return 'test-gap'; + if (finding.state_matrix === UNTESTED_CELL && String(finding.severity ?? '').toUpperCase() !== 'CRITICAL') return 'test-gap'; return 'defect'; } @@ -153,6 +185,8 @@ export interface ScopeInput { policy: ReviewScopePolicy; pass: number; diffHunks /** * The scope decision per finding. `all`: fix everything (today's loop). + * An untested state-matrix cell mapped below the floor is a note from pass 1 on + * (docs/review-policy.md, the test-gap ruling) under the other policies. * `frozen` pass 1: fix the diff, defer blocking findings in unchanged code, * note the rest. `frozen` pass 2+: fix blocking findings in the range since * the previous pass or in the diff; defer blocking findings outside; note @@ -170,6 +204,7 @@ export function scopeFindings(findings: readonly ScopeFinding[], input: ScopeInp const placed = typeof finding.path === 'string' || fingerprintPath(finding.fingerprint) !== undefined; let scope: ScopeAction; if (input.policy === 'all') scope = 'fix'; + else if (kind === 'test-gap' && finding.state_matrix === UNTESTED_CELL) scope = 'note'; else if (locus === 'outside') scope = !blocking ? 'note' : input.policy === 'diff-plus-blocking' || !placed ? 'fix' : 'defer'; else if (input.pass <= 1 && !AFTER_PASS_RAISERS.includes(raised)) scope = 'fix'; else scope = blocking ? 'fix' : 'note'; diff --git a/lib/ship-preflight.ts b/lib/ship-preflight.ts index e66d8dbbd..8b27fcad9 100644 --- a/lib/ship-preflight.ts +++ b/lib/ship-preflight.ts @@ -7,14 +7,11 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { classifyPath } from './review-depth'; +import { classifyDiff } from './review-depth'; import { audit } from './ship-audit'; import { matchesAny } from './ship-policy'; import { bin, firstLine, flagReader, gh, git, hostedOrigin, laneLine, lanes, Report, runLanes, shipPolicy, short } from './ship-bin-common'; -/** Data files: code-class paths with no runtime behavior, left out of RUNTIME_LINES. */ -const DATA_PATH = /\.(json|jsonl|ndjson|ya?ml|toml|csv|tsv|svg|png|jpe?g|gif|webp|ico|pdf)$/i; - export const PREFLIGHT_HELP = `gstack-ship-preflight — /ship Steps 1-3 and Step 5's pregate and lanes in one call (ship/SKILL.md) gstack-ship-preflight --base [--lane