mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
feat(review-packet): Step 14.5 docs audit as one read-only packet (GSTA-167)
gstack-review-packet build --audit docs --candidate <snapshot> writes one packet: the auditor prompt from the documentation section, the candidate's doc surfaces, doc lines naming a changed code path, and the docs-plus-code diff. The reader proposes edits as findings; gstack-docs-candidate apply makes each unique-match edit outside protected and generated files and prints the audit result line the section already validates. On Capy the section dispatches the packet reader; other hosts keep the /document-release subagent. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
dbe700417c
commit
890ba7bda8
9 files changed
+455
-17
No files matched your search
@@ -6,6 +6,7 @@
|
||||
* gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only
|
||||
* --base <rev> [--select <path>]... [--docs <path>]...
|
||||
* gstack-docs-candidate compare <candidate.json>
|
||||
* gstack-docs-candidate apply <candidate.json> --findings <reader out file>
|
||||
* gstack-docs-candidate [snapshot|compare] --help
|
||||
*
|
||||
* snapshot saves, NUL-safely and without writing to the repository: base SHA,
|
||||
@@ -20,10 +21,14 @@
|
||||
* the index changed, which recorded paths' content changed or disappeared, and
|
||||
* which paths are newly dirty or untracked. It never decides whether a change
|
||||
* was permitted; the caller checks those paths against the child's report.
|
||||
*
|
||||
* apply makes a packet reader's proposed doc edits (lib/docs-audit-apply.ts) and
|
||||
* prints the audit result a /document-release child would return, LAST line.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { applyDocsFindings } from '../lib/docs-audit-apply';
|
||||
|
||||
function git(args: string[]): string {
|
||||
const r = spawnSync('git', args, { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, maxBuffer: 256 * 1024 * 1024 });
|
||||
@@ -83,7 +88,8 @@ function one(args: string[], name: string): string {
|
||||
}
|
||||
|
||||
const USAGE = `usage: gstack-docs-candidate snapshot --out <candidate.json> --audit-id <id> --mode edit|read-only --base <rev> [--select <path>]... [--docs <path>]...
|
||||
gstack-docs-candidate compare <candidate.json>`;
|
||||
gstack-docs-candidate compare <candidate.json>
|
||||
gstack-docs-candidate apply <candidate.json> --findings <reader out file>`;
|
||||
|
||||
/** Each field compare needs that the record lacks or types wrongly; empty for a valid record. */
|
||||
function recordProblems(record: any): string[] {
|
||||
@@ -98,7 +104,7 @@ function recordProblems(record: any): string[] {
|
||||
function main(argv: string[]): void {
|
||||
const [command, ...args] = argv;
|
||||
const topHelp = (command === '--help' || command === '-h') && !args.length;
|
||||
const commandHelp = (command === 'snapshot' || command === 'compare') && args.length === 1 && args[0] === '--help';
|
||||
const commandHelp = (command === 'snapshot' || command === 'compare' || command === 'apply') && args.length === 1 && args[0] === '--help';
|
||||
if (topHelp || commandHelp) {
|
||||
console.log(USAGE);
|
||||
return;
|
||||
@@ -160,6 +166,15 @@ function main(argv: string[]): void {
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (command === 'apply') {
|
||||
if (args.length !== 3 || args[1] !== '--findings') throw new Error('apply takes <candidate.json> --findings <file>');
|
||||
const record = JSON.parse(fs.readFileSync(args[0]!, 'utf8'));
|
||||
if (typeof record?.audit_id !== 'string' || !Array.isArray(record.docs)) throw new Error(`${args[0]} is not a gstack-docs-candidate record (audit_id, docs)`);
|
||||
const out = applyDocsFindings(fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()), record, fs.existsSync(args[2]!) ? fs.readFileSync(args[2]!, 'utf8') : '');
|
||||
console.log(out.lines.join('\n'));
|
||||
process.exitCode = out.exit;
|
||||
return;
|
||||
}
|
||||
throw new Error(USAGE);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* [--findings <merged.jsonl>] [--fix-list <f> --tests <f> [--callers <f>]] [--fanout batched|parallel] [plan flags]
|
||||
* gstack-review-packet build --audit test-coverage|plan-completion --base <ref> --out-dir <dir> [--plan-file <path>]
|
||||
* [--context <file>] [--passes-used <n>]
|
||||
* gstack-review-packet build --audit docs --candidate <snapshot.json> --base <ref> --out-dir <dir>
|
||||
* gstack-review-packet collect --dir <dir>
|
||||
*
|
||||
* build runs the plan gstack-review-plan prints (lib/review-plan-live.ts, same flags) and writes
|
||||
@@ -22,6 +23,7 @@ import { reviewPlanLine } from '../lib/review-depth';
|
||||
import { flagReader, livePlan, parsePlanFlags, planPass, PLAN_FLAGS_HELP } from '../lib/review-plan-live';
|
||||
import { AUDITS, auditCodePaths, auditDraft, budgetRole, contentKey, draftFor, packetLine, packSpecialists, PACKET_CAP_BYTES, PACKINGS, READ_CHUNK_BYTES, readManifest, SPECIALIST_PACKING, writeManifest, writePackets, type AuditName, type BuildContext, type PacketEntry, type Packing } from '../lib/review-packet';
|
||||
import { resolveBaseRef } from '../lib/review-collect';
|
||||
import { docsAuditDraft, readCandidate } from '../lib/review-packet-docs';
|
||||
import { collect } from '../lib/review-packet-collect';
|
||||
|
||||
const BIN = 'gstack-review-packet';
|
||||
@@ -39,6 +41,9 @@ const HELP = `${BIN} — one packet file per /ship reviewer; collect reads their
|
||||
--audit test-coverage|plan-completion the Step 7/8 audit packet instead of the plan's reviewers; a diff with
|
||||
no code path prints AUDIT_SKIPPED: test-coverage and builds nothing
|
||||
--context <file> --passes-used <n> the audit's parent-supplied inputs (the coverage packet is audit-only)
|
||||
--audit docs --candidate <f> the Step 14.5 documentation audit: one read-only packet over the gstack-docs-candidate
|
||||
snapshot (doc surfaces, doc lines naming changed code, that diff); the reader
|
||||
proposes edits as findings and gstack-docs-candidate apply makes them
|
||||
plan flags (same as gstack-review-plan):
|
||||
${PLAN_FLAGS_HELP}
|
||||
collect --dir <dir> read every packet's output: one REVIEWER: line each, every CRITICAL on one
|
||||
@@ -81,8 +86,10 @@ function build(): number {
|
||||
const live = livePlan(flags, binDir, BIN);
|
||||
if ('errors' in live) { process.stderr.write(live.errors.map(e => e + '\n').join('')); return EXIT.fail; }
|
||||
const { plan, collected, head, root } = live;
|
||||
const range = flag('--range');
|
||||
if (range !== undefined && spawnSync('git', ['rev-parse', '--verify', '-q', `${range}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 15_000 }).status !== 0) return fail(`--range ${range} is not a commit`);
|
||||
const candidate = audit === 'docs' ? readCandidate(flag('--candidate')) : null;
|
||||
if (candidate && 'error' in candidate) return fail(candidate.error);
|
||||
const range = candidate?.base ?? flag('--range');
|
||||
if (range !== undefined && spawnSync('git', ['rev-parse', '--verify', '-q', `${range}^{commit}`], { cwd: root, encoding: 'utf8', timeout: 15_000 }).status !== 0) return fail(`${candidate ? "the candidate's base" : '--range'} ${range} is not a commit`);
|
||||
const diffBase = range ?? collected.mergeBase!;
|
||||
fs.mkdirSync(absOut, { recursive: true, mode: 0o700 });
|
||||
const ctx: BuildContext = { root, skillRoot: path.resolve(binDir, '..'), binDir, outDir: absOut, head, diffBase, baseRef: resolveBaseRef(root, flags.base), fingerprint: plan.numbers.fingerprint, learnings: learningsFor(binDir),
|
||||
@@ -98,7 +105,9 @@ function build(): number {
|
||||
if (context === null) return fail(`--context ${flag('--context')} is not readable`);
|
||||
if (audit === 'plan-completion' && planText === undefined) return fail('--audit plan-completion needs --plan-file <the bound plan>');
|
||||
const codePaths = audit === 'test-coverage' ? auditCodePaths(collected.paths, root) : null;
|
||||
if (codePaths?.length === 0) {
|
||||
if (candidate) {
|
||||
entries = writePackets(docsAuditDraft(ctx, flags.base, live.auditEffort, candidate), ctx);
|
||||
} else if (codePaths?.length === 0) {
|
||||
const shown = collected.paths.slice(0, 5).join(',') + (collected.paths.length > 5 ? `,+${collected.paths.length - 5} more` : '');
|
||||
out.push(`AUDIT_SKIPPED: test-coverage code_paths=0 (docs, data and tests only: ${shown || 'empty diff'}); no audit dispatched, the coverage gate records zero paths`);
|
||||
} else {
|
||||
|
||||
@@ -384,7 +384,11 @@ The Red Team's packet waits for the specialists (`--only red-team --findings
|
||||
test-coverage|plan-completion` writes a Step 7/8 audit packet from the generated
|
||||
section's prompt. The coverage audit packet is read-only (three calls: read, write, reply;
|
||||
the parent writes and runs the tests it proposes), and a diff with no code path (docs,
|
||||
data and tests only) prints `AUDIT_SKIPPED:` and builds none. An outside backend gets the outside-voice packet as its prompt file.
|
||||
data and tests only) prints `AUDIT_SKIPPED:` and builds none. `--audit docs --candidate <snapshot>`
|
||||
writes Step 14.5's documentation audit on Capy: one read-only packet holding the documentation
|
||||
section's auditor prompt, the candidate's doc surfaces, the doc lines that name a changed code
|
||||
path and the diff of the changed docs plus that code. The reader proposes edits as findings;
|
||||
`gstack-docs-candidate apply` makes the unique, permitted ones and prints the audit result. An outside backend gets the outside-voice packet as its prompt file.
|
||||
|
||||
`gstack-review-packet collect --dir <dir>` reads every output file and prints at most
|
||||
60 lines: one `REVIEWER:` line per packet (`clean`, `findings`, `missing`, `invalid`
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* docs-audit-apply — gstack-docs-candidate apply: the parent's mechanical half of the
|
||||
* Step 14.5 packet audit (lib/review-packet-docs.ts). The read-only reader writes one
|
||||
* JSON finding per line and a LAST-line object; apply makes each \`edit\` whose \`old\`
|
||||
* text occurs exactly once in a doc the candidate lists, refuses protected, generated,
|
||||
* out-of-tree and read-only-mode edits as blockers, turns \`block\` findings into
|
||||
* blockers and \`note\` findings into decisions, and prints the audit result in the
|
||||
* schema a /document-release child returns (ship/sections/documentation.md).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/** Files a documentation edit never touches: release metadata, manifests and lockfiles the parent owns. */
|
||||
const PROTECTED = [/^CHANGELOG\.md$/, /^TODOS\.md$/, /^VERSION$/, /(^|\/)package(-lock)?\.json$/, /(^|\/)(bun\.lockb?|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gemfile\.lock)$/, /(^|\/)sections\/manifest\.json$/, /^agents-digest\//];
|
||||
const GENERATED = /^(?:[^\n]*\n){0,2}<!-- AUTO-GENERATED/;
|
||||
|
||||
export interface DocsAuditResult {
|
||||
schema_version: 1; audit_id: string; status: 'updated' | 'current' | 'blocked';
|
||||
files_updated: string[]; files_reviewed: string[]; blockers: string[]; decisions: string[]; documentation_section: string;
|
||||
}
|
||||
|
||||
const strings = (v: unknown) => Array.isArray(v) ? [...new Set(v.filter((s): s is string => typeof s === 'string' && s.length > 0))] : [];
|
||||
|
||||
/** Apply the reader's findings in root; exit 0 updated/current, 3 blocked, 1 when the output is not a usable audit result. */
|
||||
export function applyDocsFindings(root: string, candidate: { audit_id: string; mode: string; docs: string[] }, text: string): { lines: string[]; result?: DocsAuditResult; exit: number } {
|
||||
const rows = text.split('\n').map(l => l.trim()).filter(Boolean);
|
||||
let last: any;
|
||||
try { last = JSON.parse(rows.at(-1) ?? ''); } catch { last = undefined; }
|
||||
if (!last || typeof last !== 'object' || last.schema_version !== 1 || last.audit_id !== candidate.audit_id || typeof last.documentation_section !== 'string' || !last.documentation_section.trim()) {
|
||||
return { lines: [`DOCS_AUDIT_UNUSABLE: the LAST line is not {"schema_version":1,"audit_id":"${candidate.audit_id}",…,"documentation_section":"…"}; the audit is blocked (Blocked recovery)`], exit: 1 };
|
||||
}
|
||||
const realRoot = fs.realpathSync(root);
|
||||
const blockers: string[] = [];
|
||||
const decisions: string[] = [];
|
||||
const edited = new Map<string, { before: string; now: string; edits: number }>();
|
||||
rows.slice(0, -1).forEach((row, i) => {
|
||||
let f: any;
|
||||
try { f = JSON.parse(row); } catch { f = undefined; }
|
||||
if (!f || typeof f !== 'object' || typeof f.path !== 'string' || typeof f.summary !== 'string' || !['edit', 'block', 'note'].includes(f.action)) {
|
||||
blockers.push(`finding line ${i + 1} is not a finding object (path, action edit|block|note, summary)`);
|
||||
return;
|
||||
}
|
||||
const where = `${f.path}${Number.isInteger(f.line) ? `:${f.line}` : ''}`;
|
||||
if (f.action === 'note') { decisions.push(`${where}: ${f.summary}`); return; }
|
||||
if (f.action === 'block') { blockers.push(`${where}: ${f.summary}`); return; }
|
||||
const refuse = (why: string) => blockers.push(`${where}: ${f.summary} (edit not applied: ${why})`);
|
||||
if (candidate.mode === 'read-only') return refuse('read-only mode');
|
||||
if (typeof f.old !== 'string' || !f.old || typeof f.new !== 'string') return refuse('an edit needs nonempty old and a new string');
|
||||
if (!candidate.docs.includes(f.path)) return refuse('not a doc in the candidate');
|
||||
if (PROTECTED.some(r => r.test(f.path))) return refuse('protected file; the parent owns it');
|
||||
let file: string;
|
||||
try { file = fs.realpathSync(path.join(realRoot, f.path)); } catch { return refuse('file not found'); }
|
||||
if (!file.startsWith(realRoot + path.sep)) return refuse('resolves outside the repository');
|
||||
const entry = edited.get(f.path) ?? (() => { const before = fs.readFileSync(file, 'utf8'); return { before, now: before, edits: 0 }; })();
|
||||
if (GENERATED.test(entry.before)) return refuse('generated file; edit its template');
|
||||
const at = entry.now.indexOf(f.old);
|
||||
if (at < 0 || entry.now.indexOf(f.old, at + 1) >= 0) return refuse(at < 0 ? 'old text not found' : 'old text occurs more than once');
|
||||
entry.now = entry.now.slice(0, at) + f.new + entry.now.slice(at + f.old.length);
|
||||
entry.edits++;
|
||||
edited.set(f.path, entry);
|
||||
});
|
||||
const files_updated: string[] = [];
|
||||
const lines: string[] = [];
|
||||
for (const [p, e] of [...edited].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (e.now === e.before) continue;
|
||||
fs.writeFileSync(path.join(realRoot, p), e.now);
|
||||
files_updated.push(p);
|
||||
lines.push(`APPLIED: ${p} edits=${e.edits}`);
|
||||
}
|
||||
for (const b of blockers) lines.push(`BLOCKER: ${b}`);
|
||||
const status = blockers.length ? 'blocked' : files_updated.length ? 'updated' : 'current';
|
||||
const summary = status === 'updated' ? `updated ${files_updated.join(', ')}` : status === 'current' ? 'current, no edits needed' : `blocked: ${blockers.length} blocker(s)${files_updated.length ? `; edited ${files_updated.join(', ')}` : ''}`;
|
||||
const section = `**Status:** ${status} (${summary})\n\n${String(last.documentation_section).replace(/^\*\*Status:\*\*[^\n]*\n+/, '').trim()}`;
|
||||
const result: DocsAuditResult = { schema_version: 1, audit_id: candidate.audit_id, status, files_updated, files_reviewed: strings(last.files_reviewed), blockers, decisions, documentation_section: section };
|
||||
lines.push(`DOCS_AUDIT: status=${status} applied=${files_updated.length} blockers=${blockers.length} notes=${decisions.length}`, JSON.stringify(result));
|
||||
return { lines, result, exit: status === 'blocked' ? 3 : 0 };
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* review-packet-docs — the Step 14.5 documentation audit as one read-only packet
|
||||
* (gstack-review-packet build --audit docs; docs/review-policy.md#reviewer-packets).
|
||||
* The packet holds the auditor prompt (the generated documentation section's text
|
||||
* fence), the doc surfaces of the gstack-docs-candidate snapshot, the doc lines that
|
||||
* name a changed code path, and the diff of the changed docs plus the code they
|
||||
* describe. The reader proposes edits as findings; gstack-docs-candidate apply
|
||||
* makes them (lib/docs-audit-apply.ts).
|
||||
*/
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { classifyPath } from './review-depth';
|
||||
import { auditPrompt, detectStack, reviewerDiff, type BuildContext, type PacketDraft } from './review-packet';
|
||||
|
||||
/** Doc lines naming a changed code path the packet quotes, at most. */
|
||||
export const DOCS_NAMED_LINES = 120;
|
||||
/** Diff bytes of code no doc names that the packet still shows; past this the stat lists them. */
|
||||
export const DOCS_CODE_DIFF_BYTES = 80_000;
|
||||
const DOCS_LIST_CAP = 300;
|
||||
const DOC_SCAN_BYTES = 512 * 1024;
|
||||
|
||||
export interface DocsCandidate { audit_id: string; mode: 'edit' | 'read-only'; base: string; docs: string[] }
|
||||
|
||||
/** The candidate record gstack-docs-candidate snapshot wrote, or why it cannot be used. */
|
||||
export function readCandidate(file: string | undefined): DocsCandidate | { error: string } {
|
||||
if (!file) return { error: '--audit docs needs --candidate <the Prepare snapshot record>' };
|
||||
let r: any;
|
||||
try { r = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { error: `--candidate ${file} is not a readable gstack-docs-candidate record` }; }
|
||||
if (r?.schema_version !== 1 || typeof r.audit_id !== 'string' || !['edit', 'read-only'].includes(r.mode) || typeof r.base !== 'string' || !Array.isArray(r.docs)) {
|
||||
return { error: `--candidate ${file} lacks schema_version 1, audit_id, mode, base or docs: rerun the Prepare snapshot` };
|
||||
}
|
||||
return { audit_id: r.audit_id, mode: r.mode, base: r.base, docs: r.docs.filter((d: unknown): d is string => typeof d === 'string') };
|
||||
}
|
||||
|
||||
const runArtifact = (ctx: BuildContext) => (p: string) => /^\.gstack\/[^/]+\//.test(p) || path.resolve(ctx.root, p).startsWith(path.resolve(ctx.outDir) + path.sep);
|
||||
|
||||
/** Tokens a doc line names a code path by: the path, its file name, and the name without its extension when distinctive. */
|
||||
function namesOf(p: string): string[] {
|
||||
const file = path.basename(p);
|
||||
const stem = file.replace(/\.[^.]+$/, '');
|
||||
return [...new Set([p, file, ...(stem.length >= 5 && stem !== file ? [stem] : [])])];
|
||||
}
|
||||
|
||||
/** Each doc line that names a changed code path, and which code paths some doc names. */
|
||||
export function namedLines(root: string, docs: readonly string[], code: readonly string[]): { lines: string[]; total: number; named: Set<string> } {
|
||||
const lines: string[] = [];
|
||||
const named = new Set<string>();
|
||||
let total = 0;
|
||||
const tokens = code.map(p => ({ p, names: namesOf(p) }));
|
||||
for (const doc of docs) {
|
||||
const file = path.join(root, doc);
|
||||
let text: string;
|
||||
try {
|
||||
const real = fs.realpathSync(file);
|
||||
if (!real.startsWith(fs.realpathSync(root) + path.sep) || fs.statSync(real).size > DOC_SCAN_BYTES) continue;
|
||||
text = fs.readFileSync(real, 'utf8');
|
||||
} catch { continue; }
|
||||
text.split('\n').forEach((line, i) => {
|
||||
const hit = tokens.filter(t => t.names.some(n => line.includes(n)));
|
||||
if (!hit.length) return;
|
||||
hit.forEach(t => named.add(t.p));
|
||||
total++;
|
||||
if (lines.length < DOCS_NAMED_LINES) lines.push(`${doc}:${i + 1}: ${line.trim().slice(0, 200)}`);
|
||||
});
|
||||
}
|
||||
return { lines, total, named };
|
||||
}
|
||||
|
||||
const fenced = (s: string) => { const f = '`'.repeat(Math.max(3, ...[...s.matchAll(/`+/g)].map(m => m[0].length + 1))); return `${f}\n${s.replace(/\n?$/, '\n')}${f}`; };
|
||||
|
||||
export function docsAuditDraft(ctx: BuildContext, base: string, effort: string, cand: DocsCandidate): PacketDraft {
|
||||
const { stack } = detectStack(ctx.root);
|
||||
const diff = reviewerDiff(ctx.root, ctx.diffBase, [], false, runArtifact(ctx));
|
||||
const isDoc = (p: string) => cand.docs.includes(p) || classifyPath(p, ctx.root) === 'docs';
|
||||
const code = diff.paths.filter(p => !isDoc(p) && classifyPath(p, ctx.root) === 'code');
|
||||
const found = namedLines(ctx.root, cand.docs, code);
|
||||
let budget = DOCS_CODE_DIFF_BYTES;
|
||||
const shown = diff.chunks.filter((c, i) => {
|
||||
const p = diff.paths[i]!;
|
||||
if (isDoc(p) || found.named.has(p)) return true;
|
||||
if (!code.includes(p) || Buffer.byteLength(c) > budget) return false;
|
||||
budget -= Buffer.byteLength(c);
|
||||
return true;
|
||||
});
|
||||
const left = diff.chunks.length - shown.length;
|
||||
const changed = new Set(diff.paths);
|
||||
const surfaces = cand.docs.slice(0, DOCS_LIST_CAP).map(d => `- ${d}${changed.has(d) ? ' (changed)' : ''}`);
|
||||
if (cand.docs.length > DOCS_LIST_CAP) surfaces.push(`- … ${cand.docs.length - DOCS_LIST_CAP} more in the candidate record`);
|
||||
const prompt = auditPrompt(ctx.skillRoot, 'docs', base, ctx.baseRef).replaceAll('<audit-id>', cand.audit_id).replaceAll('<mode>', cand.mode);
|
||||
const head = [`Role: docs audit (effort=${effort}). Audit id: ${cand.audit_id}. Mode: ${cand.mode}.`, '',
|
||||
`## Instructions\n\n${prompt}`, '',
|
||||
`## Output\n\nWrite your findings and the LAST-line JSON object the instructions name to the output path below in one write. Then reply with exactly one line, \`DONE <NAME>\`.`, '',
|
||||
`## Context\n\nStack: ${stack}. Diff base: \`${ctx.diffBase}\` (the candidate's base); head \`${ctx.head}\`.`, '',
|
||||
`## Doc surfaces (${cand.docs.length}, from the candidate)\n\n${surfaces.join('\n') || '- none: no documentation file is in scope; describe that scope in documentation_section'}`, '',
|
||||
`## Doc lines that name a changed code path (${found.total}${found.total > found.lines.length ? `, first ${found.lines.length}` : ''})\n\n${fenced(found.lines.join('\n') || '(none)')}`, '',
|
||||
...(left ? [`${left} changed file(s) are in the stat but not the diff below (tests, data, or code no doc names past ${DOCS_CODE_DIFF_BYTES} bytes); read a named range of one only if a doc line above depends on it.`, ''] : [])];
|
||||
return { role: 'audit', name: 'docs', effort, checklists: [], summary: false, head, tail: [], diff: { ...diff, chunks: shown },
|
||||
diffLabel: 'Diff of the changed docs and the code they describe', diffCmd: `git diff -U8 ${ctx.diffBase}` };
|
||||
}
|
||||
@@ -24,7 +24,9 @@ export const READ_CHUNK_BYTES = 30_000;
|
||||
/** A data-format file (.json/.jsonl/.ndjson/.csv/.tsv, not a package or config manifest) past this many changed lines reaches reviewers as a summary. */
|
||||
export const DATA_SUMMARY_LINES = 400;
|
||||
export const MANIFEST = 'manifest.json';
|
||||
export const AUDITS = ['test-coverage', 'plan-completion'] as const;
|
||||
export const AUDITS = ['test-coverage', 'plan-completion', 'docs'] as const;
|
||||
/** The ship section whose text fence is an audit's prompt. */
|
||||
const AUDIT_SECTION: Record<AuditName, string> = { 'test-coverage': 'test-coverage', 'plan-completion': 'plan-completion', docs: 'documentation' };
|
||||
export type AuditName = (typeof AUDITS)[number];
|
||||
export type PacketRole = Role | 'audit';
|
||||
|
||||
@@ -299,9 +301,9 @@ export function draftFor(d: Dispatch, ctx: BuildContext): PacketDraft {
|
||||
|
||||
/** An audit's prompt is the generated section's text fence: the packet and the SKILL.md prose are one text. */
|
||||
export function auditPrompt(skillRoot: string, audit: AuditName, base: string, baseRef = `origin/${base}`): string {
|
||||
const section = fs.readFileSync(path.join(skillRoot, 'ship', 'sections', `${audit}.md`), 'utf8');
|
||||
const section = fs.readFileSync(path.join(skillRoot, 'ship', 'sections', `${AUDIT_SECTION[audit]}.md`), 'utf8');
|
||||
const m = /^(`{4,})text\n([\s\S]*?)\n\1$/m.exec(section);
|
||||
if (!m) throw new Error(`ship/sections/${audit}.md has no prompt fence`);
|
||||
if (!m) throw new Error(`ship/sections/${AUDIT_SECTION[audit]}.md has no prompt fence`);
|
||||
return m[2]!.replaceAll('origin/<base>', baseRef).replaceAll('<base>', base);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ Reentry never resets the count or authorizes a launch.
|
||||
|
||||
## Prepare the candidate
|
||||
|
||||
1. Read the installed document-release SKILL.md and its full audit-scope/release-body
|
||||
1. **On Capy (`GSTACK_CAPY=1`) the audit is one packet** and needs no document-release
|
||||
read: skip to item 2. Elsewhere, read the installed document-release SKILL.md and its full audit-scope/release-body
|
||||
content (sections, or inlined on external hosts). A missing section
|
||||
or old `Ship-owned documentation mode` blocks before launch; never substitute.
|
||||
2. Select the base SHA. Store-only audits compare source/build content to a known
|
||||
@@ -35,7 +36,31 @@ Reentry never resets the count or authorizes a launch.
|
||||
|
||||
## Launch the audit
|
||||
|
||||
**Dispatch /document-release as a subagent** with the Agent tool (never Skill),
|
||||
**On Capy, by packet:** `~/.claude/skills/gstack/bin/gstack-review-packet build --audit docs --candidate <private.json> --base <base> --out-dir .gstack/tmp/review-packets-docs`
|
||||
writes one read-only packet: the auditor prompt below, the candidate's doc surfaces, the doc
|
||||
lines that name a changed code path and the diff of the changed docs plus that code. A refused
|
||||
build blocks before launch. Dispatch one subagent whose whole brief is Step 7's packet brief
|
||||
with this packet's `read=` ranges; retain the child id. It proposes edits as findings and
|
||||
never writes a doc.
|
||||
|
||||
````text
|
||||
You are the /ship documentation auditor (Step 14.5). Read-only: edit no file, run no command, never commit, push or start another workflow. The parent applies your proposed edits mechanically and owns every decision you return. Audit id: <audit-id>. Mode: <mode>.
|
||||
|
||||
Checklist, against the diff below and the docs it touches or names:
|
||||
1. Coverage map: each new, changed, renamed or removed public surface (commands, flags, config keys, environment variables, endpoints, skills, exported names) and where the docs cover it (reference, how-to, tutorial, explanation). Zero coverage is a critical gap, reference-only a common gap; report gaps as debt, never write new pages.
|
||||
2. Per file: README (features, install, examples, troubleshooting); ARCHITECTURE (diagrams and components match the code; be conservative); CONTRIBUTING (would each setup command still succeed for a new contributor); CLAUDE.md/AGENTS.md (structure, commands, build and test instructions); every other listed doc: does a line contradict the diff?
|
||||
3. Cross-doc: README and project instructions agree; every doc is reachable from README.md or CLAUDE.md/AGENTS.md; diagram entities the diff renamed, split, moved or removed.
|
||||
4. Classify each needed change: `edit` for a factual correction the diff clearly warrants (a table row, a path, a count, a flag name, a structure tree); `block` for narrative or positioning text, a removal, security-model text, more than ~10 lines in one section, a new section, uncertain relevance, a generated file (name its template), CHANGELOG, TODOS, VERSION, package or lock files, or any correction in read-only mode; `note` for debt and observations.
|
||||
|
||||
Reads: this packet in its read= ranges, then at most one turn of named line ranges (200 lines or fewer) of the listed docs and the code they describe. No repository search, no other packet, no SKILL.md.
|
||||
|
||||
Write to the output path in one write: one JSON object per finding, one per line:
|
||||
{"path":"<repo-relative doc>","line":<n>,"action":"edit|block|note","kind":"stale|missing|contradiction|discoverability|coverage|diagram","summary":"<one line>","old":"<exact current text, once in that file>","new":"<replacement>"}
|
||||
`old` and `new` only on `edit`. Then the LAST line, one JSON object:
|
||||
{"schema_version":1,"audit_id":"<audit-id>","files_reviewed":["<docs you actually read>"],"documentation_section":"<Markdown: audited scope, per-file status in Documentation health form, coverage debt, diagram drift; no ## Documentation heading>"}
|
||||
````
|
||||
|
||||
**Elsewhere:** Dispatch /document-release as a subagent with the Agent tool (never Skill),
|
||||
`subagent_type: "general-purpose"`.
|
||||
|
||||
**Foreground required:** pass `run_in_background: false` when available on the Agent call — subagents run in the background by default since Claude Code v2.1.198, so omitting an available flag gives a background run. A launch receipt means it went background: await its completion notice. Dispatch through the Agent tool only: invoking the target as a Skill, or executing its workflow inline in your own context, forfeits the fresh-context isolation this dispatch exists for, even though the skill may appear in your available-skills list. (Where a step defines an inline fallback, it applies only after a dispatched subagent has failed.) Retain the child id.
|
||||
@@ -66,9 +91,13 @@ Reentry never resets the count or authorizes a launch.
|
||||
|
||||
1. **Collect.** Inspect the child handle for terminal completion and final output
|
||||
within ~10 minutes; launch metadata is not completion. On failure/deadline,
|
||||
use recovery before another writer.
|
||||
use recovery before another writer. A packet reader's result is its `out=` file:
|
||||
run `~/.claude/skills/gstack/bin/gstack-docs-candidate apply <candidate> --findings <out>`.
|
||||
It makes each `edit` whose `old` text occurs once in a candidate doc (never a protected or
|
||||
generated file, none in read-only mode), turns `block` findings and unappliable edits into
|
||||
blockers, and prints the audit result on its LAST line; that line is the output below.
|
||||
2. **Check output.** Parse only the LAST nonempty line. Require every field/type,
|
||||
exact audit id, schema, status invariant and actual spawned marker above, as
|
||||
exact audit id, schema, status invariant and, from a /document-release child, the actual spawned marker above, as
|
||||
echoed in the child output; state files are not evidence of it.
|
||||
Never default or reconstruct missing values.
|
||||
3. **Check ownership.** Run `gstack-docs-candidate compare <candidate>`.
|
||||
|
||||
@@ -18,7 +18,8 @@ Reentry never resets the count or authorizes a launch.
|
||||
|
||||
## Prepare the candidate
|
||||
|
||||
1. Read the installed document-release SKILL.md and its full audit-scope/release-body
|
||||
1. **On Capy (`GSTACK_CAPY=1`) the audit is one packet** and needs no document-release
|
||||
read: skip to item 2. Elsewhere, read the installed document-release SKILL.md and its full audit-scope/release-body
|
||||
content (sections, or inlined on external hosts). A missing section
|
||||
or old `Ship-owned documentation mode` blocks before launch; never substitute.
|
||||
2. Select the base SHA. Store-only audits compare source/build content to a known
|
||||
@@ -33,7 +34,31 @@ Reentry never resets the count or authorizes a launch.
|
||||
|
||||
## Launch the audit
|
||||
|
||||
**Dispatch /document-release as a subagent** with the Agent tool (never Skill),
|
||||
**On Capy, by packet:** `~/.claude/skills/gstack/bin/gstack-review-packet build --audit docs --candidate <private.json> --base <base> --out-dir .gstack/tmp/review-packets-docs`
|
||||
writes one read-only packet: the auditor prompt below, the candidate's doc surfaces, the doc
|
||||
lines that name a changed code path and the diff of the changed docs plus that code. A refused
|
||||
build blocks before launch. Dispatch one subagent whose whole brief is Step 7's packet brief
|
||||
with this packet's `read=` ranges; retain the child id. It proposes edits as findings and
|
||||
never writes a doc.
|
||||
|
||||
````text
|
||||
You are the /ship documentation auditor (Step 14.5). Read-only: edit no file, run no command, never commit, push or start another workflow. The parent applies your proposed edits mechanically and owns every decision you return. Audit id: <audit-id>. Mode: <mode>.
|
||||
|
||||
Checklist, against the diff below and the docs it touches or names:
|
||||
1. Coverage map: each new, changed, renamed or removed public surface (commands, flags, config keys, environment variables, endpoints, skills, exported names) and where the docs cover it (reference, how-to, tutorial, explanation). Zero coverage is a critical gap, reference-only a common gap; report gaps as debt, never write new pages.
|
||||
2. Per file: README (features, install, examples, troubleshooting); ARCHITECTURE (diagrams and components match the code; be conservative); CONTRIBUTING (would each setup command still succeed for a new contributor); CLAUDE.md/AGENTS.md (structure, commands, build and test instructions); every other listed doc: does a line contradict the diff?
|
||||
3. Cross-doc: README and project instructions agree; every doc is reachable from README.md or CLAUDE.md/AGENTS.md; diagram entities the diff renamed, split, moved or removed.
|
||||
4. Classify each needed change: `edit` for a factual correction the diff clearly warrants (a table row, a path, a count, a flag name, a structure tree); `block` for narrative or positioning text, a removal, security-model text, more than ~10 lines in one section, a new section, uncertain relevance, a generated file (name its template), CHANGELOG, TODOS, VERSION, package or lock files, or any correction in read-only mode; `note` for debt and observations.
|
||||
|
||||
Reads: this packet in its read= ranges, then at most one turn of named line ranges (200 lines or fewer) of the listed docs and the code they describe. No repository search, no other packet, no SKILL.md.
|
||||
|
||||
Write to the output path in one write: one JSON object per finding, one per line:
|
||||
{"path":"<repo-relative doc>","line":<n>,"action":"edit|block|note","kind":"stale|missing|contradiction|discoverability|coverage|diagram","summary":"<one line>","old":"<exact current text, once in that file>","new":"<replacement>"}
|
||||
`old` and `new` only on `edit`. Then the LAST line, one JSON object:
|
||||
{"schema_version":1,"audit_id":"<audit-id>","files_reviewed":["<docs you actually read>"],"documentation_section":"<Markdown: audited scope, per-file status in Documentation health form, coverage debt, diagram drift; no ## Documentation heading>"}
|
||||
````
|
||||
|
||||
**Elsewhere:** Dispatch /document-release as a subagent with the Agent tool (never Skill),
|
||||
`subagent_type: "general-purpose"`.
|
||||
|
||||
{{FOREGROUND_DISPATCH_NOTE}} Retain the child id.
|
||||
@@ -64,9 +89,13 @@ Reentry never resets the count or authorizes a launch.
|
||||
|
||||
1. **Collect.** Inspect the child handle for terminal completion and final output
|
||||
within ~10 minutes; launch metadata is not completion. On failure/deadline,
|
||||
use recovery before another writer.
|
||||
use recovery before another writer. A packet reader's result is its `out=` file:
|
||||
run `~/.claude/skills/gstack/bin/gstack-docs-candidate apply <candidate> --findings <out>`.
|
||||
It makes each `edit` whose `old` text occurs once in a candidate doc (never a protected or
|
||||
generated file, none in read-only mode), turns `block` findings and unappliable edits into
|
||||
blockers, and prints the audit result on its LAST line; that line is the output below.
|
||||
2. **Check output.** Parse only the LAST nonempty line. Require every field/type,
|
||||
exact audit id, schema, status invariant and actual spawned marker above, as
|
||||
exact audit id, schema, status invariant and, from a /document-release child, the actual spawned marker above, as
|
||||
echoed in the child output; state files are not evidence of it.
|
||||
Never default or reconstruct missing values.
|
||||
3. **Check ownership.** Run `gstack-docs-candidate compare <candidate>`.
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Step 14.5 by packet (GSTA-167 PR 4): gstack-review-packet build --audit docs writes one
|
||||
* read-only auditor packet over a gstack-docs-candidate snapshot, and
|
||||
* gstack-docs-candidate apply makes the reader's proposed edits. Fixture repositories
|
||||
* live under the OS temp directory, outside this checkout, with an explicit seed identity.
|
||||
*/
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { auditPrompt } from '../lib/review-packet';
|
||||
import { DOCS_NAMED_LINES } from '../lib/review-packet-docs';
|
||||
|
||||
const ROOT = path.join(import.meta.dir, '..');
|
||||
const PACKET = path.join(ROOT, 'bin', 'gstack-review-packet');
|
||||
const CANDIDATE = path.join(ROOT, 'bin', 'gstack-docs-candidate');
|
||||
const dirs: string[] = [];
|
||||
afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); });
|
||||
const tmp = (p: string) => { const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), p))); dirs.push(d); return d; };
|
||||
|
||||
interface Fx { dir: string; home: string; out: string; run(bin: string, args: string[]): ReturnType<typeof spawnSync>; candidate(mode?: string, docs?: string[]): string }
|
||||
function fixture(base: Record<string, string>, feat: Record<string, string>): Fx {
|
||||
const dir = tmp('docs-packet-');
|
||||
expect(path.relative(ROOT, dir).startsWith('..')).toBe(true);
|
||||
const home = tmp('docs-packet-home-');
|
||||
const git = (...a: string[]) => spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 });
|
||||
const write = (files: Record<string, string>) => { for (const [p, t] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, p)), { recursive: true }); fs.writeFileSync(path.join(dir, p), t); } };
|
||||
git('init', '-q', '-b', 'main'); git('config', 'user.email', 'fixture@example.com'); git('config', 'user.name', 'Fixture'); git('config', 'commit.gpgsign', 'false');
|
||||
write(base); git('add', '.');
|
||||
expect(git('commit', '-qm', 'base').status).toBe(0);
|
||||
git('checkout', '-qb', 'feat'); write(feat); git('add', '.');
|
||||
expect(git('commit', '-qm', 'feat').status).toBe(0);
|
||||
const run = (bin: string, args: string[]) => spawnSync(bin, args, { cwd: dir, encoding: 'utf8', timeout: 120_000, env: { ...process.env, GSTACK_STATE_ROOT: path.join(home, 'state') } });
|
||||
return { dir, home, out: path.join(home, 'packets'), run, candidate: (mode = 'edit', docs = ['README.md', 'docs']) => {
|
||||
const file = path.join(home, `cand-${mode}.json`);
|
||||
const r = run(CANDIDATE, ['snapshot', '--out', file, '--audit-id', 'audit-7', '--mode', mode, '--base', 'main', ...docs.flatMap(d => ['--docs', d])]);
|
||||
expect(r.status).toBe(0);
|
||||
return file;
|
||||
} };
|
||||
}
|
||||
const packetOf = (stdout: string) => { const m = /^PACKET: audit docs .* read=(\S+) file=(\S+) out=(\S+)$/m.exec(stdout); return m ? { read: m[1]!, file: m[2]!, out: m[3]!, text: fs.readFileSync(m[2]!, 'utf8') } : null; };
|
||||
|
||||
const BASE = {
|
||||
'README.md': '# Tool\n\nRun `bin/tool-run --fast` to start.\n\nUser-maintained note: KEEP.\n',
|
||||
'docs/guide.md': '# Guide\n\nThe parser lives in src/parser.ts.\n',
|
||||
'docs/other.md': '# Other\n\nUnrelated page.\n',
|
||||
'src/parser.ts': 'export const parse = 1;\n',
|
||||
'src/unrelated.ts': 'export const UNRELATED = 1;\n',
|
||||
'bin/tool-run': '#!/bin/sh\necho fast\n',
|
||||
};
|
||||
|
||||
describe('build --audit docs', () => {
|
||||
// Value: protects=the auditor reads one packet holding its prompt, the doc surfaces, the doc lines naming changed code and only that code's diff; fails_when=the packet drops the prompt or a doc line, or ships the diff of code no doc names past the cap, or the test diff;
|
||||
// why_new=the Step 14.5 packet is new; seam=gstack-docs-candidate snapshot; no_claim=what a model does with the packet
|
||||
test('one read-only packet: prompt with the audit id and mode, doc surfaces, doc lines naming changed code, and that diff', () => {
|
||||
const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 2;\nexport const PARSER_CHANGE = 1;\n', 'docs/guide.md': '# Guide\n\nThe parser lives in src/parser.ts. GUIDE_EDIT\n', 'test/parser.test.ts': 'TEST_PAYLOAD\n' });
|
||||
const b = f.run(PACKET, ['build', '--audit', 'docs', '--candidate', f.candidate(), '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]);
|
||||
expect(b.status).toBe(0);
|
||||
const p = packetOf(b.stdout)!;
|
||||
expect(b.stdout).toMatch(/^BUDGET_NEXT: dispatch:specialist$/m);
|
||||
expect(p.text).toContain('You are the /ship documentation auditor (Step 14.5). Read-only');
|
||||
expect(p.text).toContain('Audit id: audit-7. Mode: edit.');
|
||||
expect(p.text).not.toContain('<audit-id>');
|
||||
expect(p.text).toContain('- docs/guide.md (changed)');
|
||||
expect(p.text).toContain('- docs/other.md\n');
|
||||
expect(p.text).toContain('docs/guide.md:3: The parser lives in src/parser.ts.');
|
||||
expect(p.text).toContain('+export const PARSER_CHANGE = 1;');
|
||||
expect(p.text).toContain('GUIDE_EDIT');
|
||||
expect(p.text).not.toContain('+TEST_PAYLOAD');
|
||||
expect(p.text).toContain(`Output path: \`${p.out}\``);
|
||||
expect(p.text.trimEnd().endsWith('END OF PACKET docs')).toBe(true);
|
||||
});
|
||||
|
||||
// Value: protects=the build refuses without a usable candidate and the audit prompt comes only from the section fence; fails_when=a build without --candidate writes a packet, or a section without its fence yields an empty prompt;
|
||||
// why_new=--candidate is new; seam=none; no_claim=the parent's recovery prose
|
||||
test('a missing or unusable candidate is a usage refusal with no packet; a section without its prompt fence throws', () => {
|
||||
const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 3;\n' });
|
||||
const none = f.run(PACKET, ['build', '--audit', 'docs', '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]);
|
||||
expect(none.status).toBe(2);
|
||||
expect(none.stderr).toContain('--audit docs needs --candidate');
|
||||
const bad = path.join(f.home, 'bad.json');
|
||||
fs.writeFileSync(bad, '{"schema_version":1}');
|
||||
expect(f.run(PACKET, ['build', '--audit', 'docs', '--candidate', bad, '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]).status).toBe(2);
|
||||
expect(fs.existsSync(path.join(f.out, 'docs.packet.md'))).toBe(false);
|
||||
const skill = tmp('docs-skill-');
|
||||
fs.mkdirSync(path.join(skill, 'ship', 'sections'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skill, 'ship', 'sections', 'documentation.md'), '# no fence\n');
|
||||
expect(() => auditPrompt(skill, 'docs', 'main')).toThrow('ship/sections/documentation.md has no prompt fence');
|
||||
expect(auditPrompt(ROOT, 'docs', 'main')).toContain('"action":"edit|block|note"');
|
||||
});
|
||||
|
||||
// Value: protects=the quoted doc lines stay bounded however many lines name a changed path; fails_when=the cap is dropped and a large doc set floods the packet;
|
||||
// why_new=the cap is new; seam=none; no_claim=which lines are most useful
|
||||
test('doc lines naming changed code are capped and counted', () => {
|
||||
const many = Array.from({ length: DOCS_NAMED_LINES + 30 }, (_, i) => `line ${i} uses src/parser.ts`).join('\n') + '\n';
|
||||
const f = fixture({ ...BASE, 'docs/big.md': many }, { 'src/parser.ts': 'export const parse = 4;\n' });
|
||||
const p = packetOf(f.run(PACKET, ['build', '--audit', 'docs', '--candidate', f.candidate(), '--base', 'main', '--gate-tagged', '0', '--out-dir', f.out]).stdout)!;
|
||||
expect(p.text).toContain(`(${DOCS_NAMED_LINES + 31}, first ${DOCS_NAMED_LINES})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gstack-docs-candidate apply', () => {
|
||||
const findings = (rows: object[], last: object = { schema_version: 1, audit_id: 'audit-7', files_reviewed: ['README.md', 'docs/guide.md'], documentation_section: 'Scope: README and docs/.' }) =>
|
||||
[...rows, last].map(r => JSON.stringify(r)).join('\n') + '\n';
|
||||
const lastJson = (stdout: string) => JSON.parse(stdout.trimEnd().split('\n').at(-1)!);
|
||||
|
||||
// Value: protects=apply makes exactly the unique edits in candidate docs and reports the rest as blockers in the child result schema, and the existing ownership check sees only those paths; fails_when=an ambiguous, protected, generated, outside or uncandidated edit is written, or a block finding is dropped;
|
||||
// why_new=apply is new; seam=gstack-docs-candidate compare; no_claim=whether a proposed edit is true
|
||||
test('applies unique edits to candidate docs; protected, generated, ambiguous, uncandidated and block findings become blockers', () => {
|
||||
const f = fixture({ ...BASE, 'CHANGELOG.md': '# Changelog\n', 'docs/gen.md': '<!-- AUTO-GENERATED from gen.md.tmpl -->\nold\n', 'docs/twice.md': 'same\nsame\n' }, { 'bin/tool-run': '#!/bin/sh\necho quick\n' });
|
||||
const cand = f.candidate('edit', ['README.md', 'docs', 'CHANGELOG.md']);
|
||||
const out = path.join(f.home, 'docs.out');
|
||||
fs.writeFileSync(out, findings([
|
||||
{ path: 'README.md', line: 3, action: 'edit', kind: 'stale', summary: 'flag renamed', old: '--fast', new: '--quick' },
|
||||
{ path: 'docs/twice.md', action: 'edit', kind: 'stale', summary: 'ambiguous', old: 'same', new: 'x' },
|
||||
{ path: 'CHANGELOG.md', action: 'edit', kind: 'stale', summary: 'protected', old: '# Changelog', new: 'x' },
|
||||
{ path: 'docs/gen.md', action: 'edit', kind: 'stale', summary: 'generated', old: 'old', new: 'new' },
|
||||
{ path: 'src/parser.ts', action: 'edit', kind: 'stale', summary: 'not a doc', old: 'parse', new: 'x' },
|
||||
{ path: 'docs/other.md', action: 'block', kind: 'contradiction', summary: 'narrative rewrite needed' },
|
||||
{ path: 'docs/guide.md', action: 'note', kind: 'coverage', summary: '--quick has no how-to' },
|
||||
]));
|
||||
const r = f.run(CANDIDATE, ['apply', cand, '--findings', out]);
|
||||
expect(r.status).toBe(3);
|
||||
const res = lastJson(String(r.stdout));
|
||||
expect(res).toMatchObject({ schema_version: 1, audit_id: 'audit-7', status: 'blocked', files_updated: ['README.md'], files_reviewed: ['README.md', 'docs/guide.md'], decisions: ['docs/guide.md: --quick has no how-to'] });
|
||||
expect(res.blockers.join('\n')).toMatch(/twice\.md.*more than once[\s\S]*CHANGELOG\.md.*protected[\s\S]*gen\.md.*generated[\s\S]*parser\.ts.*not a doc[\s\S]*other\.md: narrative/);
|
||||
expect(res.documentation_section.startsWith('**Status:** blocked')).toBe(true);
|
||||
expect(fs.readFileSync(path.join(f.dir, 'README.md'), 'utf8')).toContain('bin/tool-run --quick');
|
||||
expect(fs.readFileSync(path.join(f.dir, 'README.md'), 'utf8')).toContain('User-maintained note: KEEP.');
|
||||
for (const p of ['docs/twice.md', 'CHANGELOG.md', 'docs/gen.md', 'src/parser.ts']) expect(spawnSync('git', ['diff', '--quiet', '--', p], { cwd: f.dir, timeout: 10_000 }).status).toBe(0);
|
||||
const cmp = JSON.parse(String(f.run(CANDIDATE, ['compare', cand]).stdout));
|
||||
expect(cmp).toMatchObject({ head_changed: false, index_changed: false, content_changed: ['README.md'], newly_dirty: [] });
|
||||
});
|
||||
|
||||
// Value: protects=a clean audit is current or updated with exit 0, read-only mode never writes, and an unusable last line or another audit id is exit 1; fails_when=read-only edits apply, or a stale reader output is accepted;
|
||||
// why_new=apply is new; seam=none; no_claim=the parent's recovery
|
||||
test('current and updated exit 0; read-only mode blocks every edit; a wrong audit id or prose last line is unusable', () => {
|
||||
const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 5;\n' });
|
||||
const out = path.join(f.home, 'docs.out');
|
||||
fs.writeFileSync(out, findings([]));
|
||||
const cur = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]);
|
||||
expect(cur.status).toBe(0);
|
||||
expect(lastJson(String(cur.stdout))).toMatchObject({ status: 'current', files_updated: [], blockers: [] });
|
||||
fs.writeFileSync(out, findings([{ path: 'docs/guide.md', action: 'edit', kind: 'stale', summary: 'path moved', old: 'src/parser.ts', new: 'src/parse.ts' }]));
|
||||
const ro = f.run(CANDIDATE, ['apply', f.candidate('read-only'), '--findings', out]);
|
||||
expect(ro.status).toBe(3);
|
||||
expect(lastJson(String(ro.stdout)).blockers[0]).toContain('read-only mode');
|
||||
expect(fs.readFileSync(path.join(f.dir, 'docs/guide.md'), 'utf8')).toContain('src/parser.ts');
|
||||
const up = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]);
|
||||
expect(up.status).toBe(0);
|
||||
expect(lastJson(String(up.stdout))).toMatchObject({ status: 'updated', files_updated: ['docs/guide.md'] });
|
||||
fs.writeFileSync(out, findings([], { schema_version: 1, audit_id: 'audit-other', files_reviewed: [], documentation_section: 'x' }));
|
||||
expect(f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]).status).toBe(1);
|
||||
fs.writeFileSync(out, 'All docs are current.\n');
|
||||
const prose = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]);
|
||||
expect(prose.status).toBe(1);
|
||||
expect(String(prose.stdout)).toContain('DOCS_AUDIT_UNUSABLE');
|
||||
});
|
||||
|
||||
// Value: protects=a doc symlinked outside the repository is never written through; fails_when=apply follows the link;
|
||||
// why_new=apply is new; seam=none; no_claim=symlinks inside the repository
|
||||
test('a candidate doc that resolves outside the repository is refused, never written through', () => {
|
||||
const outside = tmp('docs-outside-');
|
||||
fs.writeFileSync(path.join(outside, 'target.md'), 'secret target\n');
|
||||
const f = fixture(BASE, { 'src/parser.ts': 'export const parse = 6;\n' });
|
||||
fs.symlinkSync(path.join(outside, 'target.md'), path.join(f.dir, 'docs', 'link.md'));
|
||||
const out = path.join(f.home, 'docs.out');
|
||||
fs.writeFileSync(out, findings([{ path: 'docs/link.md', action: 'edit', kind: 'stale', summary: 'x', old: 'secret', new: 'changed' }]));
|
||||
const r = f.run(CANDIDATE, ['apply', f.candidate(), '--findings', out]);
|
||||
expect(lastJson(String(r.stdout)).blockers[0]).toContain('outside the repository');
|
||||
expect(fs.readFileSync(path.join(outside, 'target.md'), 'utf8')).toBe('secret target\n');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user