@namewta/speculo 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/src/agent-skills.js +2 -2
- package/dist/src/agent-skills.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +1636 -611
- package/template/canonical/canonical-specdev-grill-with-docs.md +237 -39
- package/template/canonical/canonical-specdev-spec.md +249 -95
- package/template/canonical/canonical-specdev-tickets.md +261 -153
- package/template/canonical/canonical-specdev-wayfinder.md +232 -28
- package/template/commands/git-history-squash/scripts/git-history-squash.mjs +4 -4
- package/template/commands/handoff/COMMAND.md +10 -0
- package/template/references/coordination/coordination-contract.md +5 -3
- package/template/references/coordination/modes/cli.md +2 -2
- package/template/references/coordination/modes/herdr.md +2 -2
- package/template/references/coordination/modes/orca.md +32 -9
- package/template/references/coordination/result-and-recovery.md +6 -3
- package/template/references/coordination/session-files.md +93 -0
- package/template/references/coordination/task-brief.md +6 -4
- package/template/workflows/specdev/common/rules/adr-format.md +5 -11
- package/template/workflows/specdev/common/rules/change-completion.md +3 -0
- package/template/workflows/specdev/common/rules/path-reference-contract.md +2 -0
- package/template/workflows/specdev/common/rules/planning-principles.md +1 -1
- package/template/workflows/specdev/common/rules/readiness-and-depth.md +5 -5
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +2 -0
- package/template/workflows/specdev/common/skills/code-review/SKILL.md +11 -8
- package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +3 -3
- package/template/workflows/specdev/common/skills/code-review/references/review-evidence.md +44 -0
- package/template/workflows/specdev/common/skills/code-review/references/reviewer-contracts.md +17 -5
- package/template/workflows/specdev/common/skills/code-review/references/working-tree-review.md +15 -0
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +2 -0
- package/template/workflows/specdev/common/tools/README.md +8 -0
- package/template/workflows/specdev/common/tools/change-artifacts.mjs +3 -0
- package/template/workflows/specdev/common/tools/plan-contract.mjs +2 -3
- package/template/workflows/specdev/common/tools/review-evidence.mjs +117 -0
- package/template/workflows/specdev/common/tools/review-scope.mjs +92 -5
- package/template/workflows/specdev/common/tools/tests/review-evidence.test.mjs +74 -0
- package/template/workflows/specdev/common/tools/tests/review-fixture.mjs +31 -0
- package/template/workflows/specdev/common/tools/tests/review-working-tree.test.mjs +60 -0
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +79 -37
- package/template/workflows/specdev/specdev-c-code-review/SKILL.md +4 -4
- package/template/workflows/specdev/specdev-c-code-review/assets/code-review-template.md +5 -1
- package/template/workflows/specdev/specdev-c-code-review/references/source-files-review.md +2 -2
- package/template/workflows/specdev/specdev-i-implement/SKILL.md +1 -1
- package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +9 -4
- package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +6 -2
- package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +1 -1
- package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +2 -0
- package/template/workflows/specdev/specdev-p-goal-plan/assets/lightweight-goal-plan-template.md +2 -0
- package/template/workflows/specdev/specdev-p-goal-plan/references/completion-control.md +3 -0
- package/template/workflows/specdev/specdev-s-spec/SKILL.md +2 -2
- package/template/workflows/specdev/specdev-s-spec/assets/spec-template.md +15 -65
- package/template/workflows/specdev/specdev-t-tickets/assets/ticket-template.md +19 -103
- package/template/workflows/specdev/specdev-t-tickets/assets/tickets-map-template.md +2 -0
- package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +5 -19
- package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +4 -4
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Read-only checks of review records. Tool receipts still require Lead verification. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { relative, resolve } from 'node:path';
|
|
5
|
+
import { markdownBodyLines, readArtifactSectionSync, readUtf8ArtifactSync, safeWorkPathSync } from '../../../../lib/speculo-runtime.mjs';
|
|
6
|
+
import { resolveReviewScope } from './review-scope.mjs';
|
|
7
|
+
|
|
8
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const real = value => typeof value === 'string' && value.trim() && !/^(?:<[^>]*>|unknown|unassigned|pending|todo|tbd|\.\.\.)$/i.test(value.trim());
|
|
10
|
+
const identity = value => real(value) && /^[a-z][a-z0-9-]*:\S+$/i.test(value) && real(value.slice(value.indexOf(':')+1));
|
|
11
|
+
function fields(text) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const {text:line} of markdownBodyLines(text)) {
|
|
14
|
+
const match = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*)$/.exec(line);
|
|
15
|
+
if (!match) continue;
|
|
16
|
+
if (Object.hasOwn(result, match[1])) throw new Error(`duplicate ${match[1]}`);
|
|
17
|
+
result[match[1]] = match[2].trim();
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function section(text, heading) {
|
|
22
|
+
const lines = markdownBodyLines(text), matches = lines.filter(line => line.text === `## ${heading}`);
|
|
23
|
+
if (matches.length !== 1) throw new Error(`expected one ${heading} section`);
|
|
24
|
+
const start = matches[0].line, end = lines.find(line => line.line > start && /^#{1,2} /.test(line.text))?.line;
|
|
25
|
+
return text.split(/\r?\n/).slice(start, end ? end-1 : undefined).join('\n');
|
|
26
|
+
}
|
|
27
|
+
function readLocator(root, locator) {
|
|
28
|
+
if (!real(locator)) throw new Error('missing original evidence locator');
|
|
29
|
+
const parts = locator.split('#');
|
|
30
|
+
if (parts.length > 2 || parts.length === 2 && !parts[1]) throw new Error('invalid evidence locator');
|
|
31
|
+
const path = safeWorkPathSync(root, parts[0] || 'README.md', {mustExist:true, type:'file'});
|
|
32
|
+
return {path, text:parts.length === 2 ? readArtifactSectionSync(path, parts[1]).text : readUtf8ArtifactSync(path)};
|
|
33
|
+
}
|
|
34
|
+
export function reviewInputDigest(input) { return hash(JSON.stringify(input)); }
|
|
35
|
+
|
|
36
|
+
/** Sources use project-relative locators; an anchor avoids a README hashing itself. */
|
|
37
|
+
export function validateIndependentReview(root, artifact, {repoRoot=null, requirePassed=true, expectedHead=null, expectedBase=null, expectedResults=null, requiredImplementers=[], requiredSpecifications=[], checkCurrent=false, requireDelivery=false, allowSourceFiles=false} = {}) {
|
|
38
|
+
const errors = [], label = `independent-review: ${artifact}`;
|
|
39
|
+
try {
|
|
40
|
+
const path = safeWorkPathSync(root, artifact, {mustExist:true, type:'file'});
|
|
41
|
+
const text = readUtf8ArtifactSync(path), body = section(text,'Independent Review'), data = fields(body);
|
|
42
|
+
const implementers = (data.Implementers ?? '').split(',').map(x=>x.trim()).filter(Boolean);
|
|
43
|
+
if (!implementers.length || implementers.some(id=>!identity(id)) || new Set(implementers).size !== implementers.length) throw new Error('Implementers must contain actual namespaced agent/session IDs');
|
|
44
|
+
if (requiredImplementers.some(id=>!implementers.includes(id))) throw new Error('implementation owner missing from Implementers');
|
|
45
|
+
if (!identity(data['Verified-By'])) throw new Error('missing actual Verified-By Lead identity');
|
|
46
|
+
if (!['native','herdr','orca','cli'].includes(data.Mode)) throw new Error('invalid review Mode');
|
|
47
|
+
const inputSource = readLocator(root,data.Input);
|
|
48
|
+
const blocks = [...inputSource.text.matchAll(/```json\s*\n([\s\S]*?)\n```/g)];
|
|
49
|
+
if (blocks.length !== 1) throw new Error('Input must reference one frozen review JSON object');
|
|
50
|
+
const input = JSON.parse(blocks[0][1]), scope = input.scope;
|
|
51
|
+
if (!scope || !Array.isArray(input.sources) || !input.sources.length) throw new Error('Input needs scope and actual standards/specification/instruction sources');
|
|
52
|
+
if (!repoRoot && checkCurrent) throw new Error('current input checks require explicit project root');
|
|
53
|
+
const sourceKinds = new Set();
|
|
54
|
+
for (const source of input.sources) {
|
|
55
|
+
if (!['standards','specification','instructions'].includes(source.kind) || !real(source.locator) || !/^[a-f0-9]{64}$/.test(source.sha256 ?? '')) throw new Error('invalid review source');
|
|
56
|
+
sourceKinds.add(source.kind);
|
|
57
|
+
if (repoRoot && checkCurrent && hash(readLocator(repoRoot,source.locator).text) !== source.sha256) throw new Error(`source drift: ${source.locator}`);
|
|
58
|
+
}
|
|
59
|
+
if (!sourceKinds.has('standards') || requirePassed && !sourceKinds.has('specification')) throw new Error('missing required review sources');
|
|
60
|
+
if (requiredSpecifications.some(locator=>!input.sources.some(source=>source.kind === 'specification' && source.locator === locator))) throw new Error('review omits the authoritative specification/acceptance source');
|
|
61
|
+
const digest = reviewInputDigest(input);
|
|
62
|
+
const rows = markdownBodyLines(body).filter(({text:line})=>/^\|\s*(standards|specification)\s*\|/.test(line)).map(({text:line})=>line.split('|').slice(1,-1).map(x=>x.trim()));
|
|
63
|
+
if (rows.length !== 2 || new Set(rows.map(row=>row[0])).size !== 2) throw new Error('expected exactly one result per axis');
|
|
64
|
+
for (const [axis,reviewer,result,locator,...extra] of rows) {
|
|
65
|
+
if (extra.length || !identity(reviewer)) throw new Error('invalid reviewer row');
|
|
66
|
+
if (implementers.includes(reviewer)) throw new Error('reviewer participated in implementation');
|
|
67
|
+
if (!['pass','request-changes','skipped'].includes(result) || requirePassed && result !== 'pass' || axis === 'standards' && result === 'skipped') throw new Error(`${axis} has not passed`);
|
|
68
|
+
if (expectedResults && expectedResults[axis] !== result) throw new Error(`${axis} result differs from the owning report`);
|
|
69
|
+
const report = readLocator(root,locator), reportFields = fields(report.text);
|
|
70
|
+
if (report.path === path && !locator.includes('#')) throw new Error('report must reference original output, not its summary');
|
|
71
|
+
if (reportFields.Reviewer !== reviewer || reportFields.Axis !== axis || reportFields.Result !== result || reportFields['Input-SHA256'] !== digest || !real(reportFields.Observed)) throw new Error(`${axis} original report does not match the review record`);
|
|
72
|
+
}
|
|
73
|
+
const receipt = fields(readLocator(root,data.Verification).text);
|
|
74
|
+
const reviewers = (receipt.Reviewers ?? '').split(',').map(x=>x.trim());
|
|
75
|
+
if (receipt['Verified-By'] !== data['Verified-By'] || receipt.Mode !== data.Mode || receipt.Result !== 'verified' || receipt['Input-SHA256'] !== digest || !real(receipt.Observed) || rows.some(row=>!reviewers.includes(row[1]))) throw new Error('missing matching Lead verification of actual dispatch/return receipts');
|
|
76
|
+
if (scope.mode === 'source-files' && !allowSourceFiles) throw new Error('source-files cannot substitute implementation change review');
|
|
77
|
+
if (!['baseline','range','commit','pr','source-files','working-tree'].includes(scope.mode)) throw new Error('invalid frozen review scope');
|
|
78
|
+
if (scope.outcome === 'no-diff') throw new Error('no-diff is not an independent approval');
|
|
79
|
+
if (expectedHead && (scope.mode === 'working-tree' ? scope.observed_head : scope.head_sha) !== expectedHead) throw new Error('review does not cover the expected checkpoint');
|
|
80
|
+
if (expectedBase && scope.base_sha !== expectedBase) throw new Error('review does not cover the implementation baseline');
|
|
81
|
+
if (scope.mode === 'working-tree' && scope.excluded_paths?.length && !real(input.exclusions)) throw new Error('excluded changes require an explicit scope/ownership rationale');
|
|
82
|
+
if (checkCurrent) {
|
|
83
|
+
const options = {repo:repoRoot,mode:scope.mode};
|
|
84
|
+
if (scope.mode === 'working-tree') { options.base=scope.base_sha; if(scope.selection === 'explicit-files') options.files=scope.scope_paths; }
|
|
85
|
+
else if (scope.mode === 'source-files') options.files=scope.source_snapshot?.map(file=>file.path);
|
|
86
|
+
else if (scope.mode === 'commit') { options.commit=scope.head_sha; if(scope.parent) options.parent=scope.parent; }
|
|
87
|
+
else { options.base=scope.base_sha; options.head=scope.head_sha; }
|
|
88
|
+
const current = resolveReviewScope(options);
|
|
89
|
+
// Re-read all scope metadata, not just the HEAD; dirty files are not commits.
|
|
90
|
+
const comparable = value => {
|
|
91
|
+
if (value.mode !== 'working-tree' || value.selection !== 'explicit-files') return value;
|
|
92
|
+
// New report files outside an explicit review scope are expected. Their
|
|
93
|
+
// existence must not create a self-referential evidence digest.
|
|
94
|
+
const {excluded_paths, inventory_sha256, input_sha256, ...selected} = value;
|
|
95
|
+
return selected;
|
|
96
|
+
};
|
|
97
|
+
if (JSON.stringify(comparable(current)) !== JSON.stringify(comparable(scope))) throw new Error('frozen review input drifted; run independent review again');
|
|
98
|
+
if (requireDelivery && scope.mode !== 'working-tree' && scope.mode !== 'source-files') {
|
|
99
|
+
// A commit report certifies that tree, not later local edits. Markdown
|
|
100
|
+
// bookkeeping in the owning change may advance after the code review;
|
|
101
|
+
// authoritative specification sources are still hashed above.
|
|
102
|
+
const live = resolveReviewScope({repo:repoRoot,mode:'working-tree',base:scope.head_sha});
|
|
103
|
+
const recordPath = file => {
|
|
104
|
+
const local = relative(resolve(root),resolve(repoRoot,file)).split('\\').join('/');
|
|
105
|
+
return /^(?:README\.md|goal-plan\.md|tickets-map\.md|implementation-(?:plan|map)\.md|(?:evidence|reviews|ticket)\/[^/]+\.md)$/.test(local);
|
|
106
|
+
};
|
|
107
|
+
const dirty = live.changed_paths.filter(file=>!recordPath(file));
|
|
108
|
+
if (dirty.length) throw new Error(`unreviewed working-tree delivery changes: ${dirty.join(', ')}; freeze and review the actual working-tree scope`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} catch(error) { errors.push(`${label}: ${error.message}`); }
|
|
112
|
+
return errors;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function hasIndependentReview(path) {
|
|
116
|
+
return existsSync(path) && markdownBodyLines(readUtf8ArtifactSync(path)).some(line=>line.text === '## Independent Review');
|
|
117
|
+
}
|
|
@@ -1,10 +1,96 @@
|
|
|
1
1
|
/** Read-only review selection. Freezes local files or Git refs; never fetches or writes state. */
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
-
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
6
6
|
|
|
7
|
-
const modes = new Set(['baseline', 'range', 'commit', 'pr', 'source-files']);
|
|
7
|
+
const modes = new Set(['baseline', 'range', 'commit', 'pr', 'source-files', 'working-tree']);
|
|
8
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
9
|
+
function portableFile(path) {
|
|
10
|
+
if (typeof path !== 'string' || !path || isAbsolute(path) || /[\\:\x00-\x1f]/.test(path) || path.split('/').some(part => !part || part === '.' || part === '..' || part.toLowerCase() === '.git')) {
|
|
11
|
+
throw new Error('review-scope: file must be an explicit portable project-relative path outside .git');
|
|
12
|
+
}
|
|
13
|
+
return path;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Read a link itself, never its target. Directory links are rejected before any
|
|
17
|
+
// file read; a file replaced by a directory is a deletion plus added children.
|
|
18
|
+
function workingFile(root, path) {
|
|
19
|
+
portableFile(path);
|
|
20
|
+
const requested = resolve(root, path);
|
|
21
|
+
for (let parent = dirname(requested); parent !== root; parent = dirname(parent)) {
|
|
22
|
+
try { if (lstatSync(parent).isSymbolicLink()) throw new Error('review-scope: symlink directory in working-tree scope'); }
|
|
23
|
+
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
24
|
+
}
|
|
25
|
+
let info;
|
|
26
|
+
try { info = lstatSync(requested); }
|
|
27
|
+
catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error; }
|
|
28
|
+
if (info.isDirectory()) return null;
|
|
29
|
+
if (info.isSymbolicLink()) {
|
|
30
|
+
const bytes = readlinkSync(requested, {encoding:'buffer'});
|
|
31
|
+
return {kind:'symlink', mode:'120000', sha256:digest(bytes), bytes:bytes.length};
|
|
32
|
+
}
|
|
33
|
+
if (!info.isFile()) throw new Error('review-scope: unsupported working-tree file type');
|
|
34
|
+
const fd = openSync(requested, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
35
|
+
try {
|
|
36
|
+
const before = fstatSync(fd, {bigint:true});
|
|
37
|
+
if (!before.isFile()) throw new Error('review-scope: working-tree file type changed');
|
|
38
|
+
const bytes = readFileSync(fd), after = fstatSync(fd, {bigint:true}), current = lstatSync(requested, {bigint:true});
|
|
39
|
+
if (!['dev','ino','size','mode','mtimeNs','ctimeNs'].every(key => before[key] === after[key] && after[key] === current[key])) throw new Error('review-scope: working-tree changed while freezing inputs');
|
|
40
|
+
return {kind:'file', mode:before.mode & 0o111n ? '100755' : '100644', sha256:digest(bytes), bytes:bytes.length};
|
|
41
|
+
} finally { closeSync(fd); }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function workingTree(options) {
|
|
45
|
+
if (['head','commit','parent'].some(key => options[key] !== undefined)) throw new Error('review-scope: working-tree accepts base, not head/commit/parent');
|
|
46
|
+
// Native realpath expands Windows short names; relative also handles drive
|
|
47
|
+
// casing without admitting a nested directory that borrows its parent's Git.
|
|
48
|
+
const root = realpathSync.native(resolve(options.repo));
|
|
49
|
+
if (relative(root,realpathSync.native(git(root, ['rev-parse','--show-toplevel']).trim())) !== '') throw new Error('review-scope: working-tree requires the actual project Git root');
|
|
50
|
+
const base = commit(root, options.base, 'base');
|
|
51
|
+
const files = options.files?.map(portableFile);
|
|
52
|
+
if (files && (!files.length || new Set(files).size !== files.length)) throw new Error('review-scope: empty or duplicate working-tree scope');
|
|
53
|
+
const capture = () => {
|
|
54
|
+
if (git(root, ['ls-files','--unmerged','-z'])) throw new Error('review-scope: unresolved index conflicts');
|
|
55
|
+
const head = commit(root, 'HEAD', 'HEAD');
|
|
56
|
+
const index = git(root, ['ls-files','--stage','-z']);
|
|
57
|
+
const inventory = git(root, ['status','--porcelain=v1','-z','--untracked-files=all']);
|
|
58
|
+
const baseEntries = new Map(git(root, ['ls-tree','-r','-z','--full-tree',base]).split('\0').filter(Boolean).map(row => {
|
|
59
|
+
const tab = row.indexOf('\t'), [mode,type,oid] = row.slice(0,tab).split(' ');
|
|
60
|
+
return [row.slice(tab+1), {mode,type,oid}];
|
|
61
|
+
}));
|
|
62
|
+
const diffArgs = ['diff','--no-color','--no-ext-diff','--no-textconv','--binary','--no-renames',base,'--'];
|
|
63
|
+
const changed = [...new Set([
|
|
64
|
+
...git(root, ['diff','--no-ext-diff','--no-textconv','--no-renames','--name-only','-z',base,'--']).split('\0'),
|
|
65
|
+
...git(root, ['ls-files','--others','--exclude-standard','-z']).split('\0'),
|
|
66
|
+
].filter(Boolean))].sort();
|
|
67
|
+
const selected = files ? [...files].sort() : changed;
|
|
68
|
+
const snapshot = selected.map(path => {
|
|
69
|
+
portableFile(path);
|
|
70
|
+
const entry = baseEntries.get(path);
|
|
71
|
+
if (entry && entry.type !== 'blob') throw new Error('review-scope: submodules require separate review; working-tree snapshot is unverified');
|
|
72
|
+
const before = entry ? {mode:entry.mode, oid:entry.oid, sha256:digest(git(root, ['cat-file','blob',entry.oid], null))} : null;
|
|
73
|
+
const after = workingFile(root, path);
|
|
74
|
+
if (!before && !after) throw new Error(`review-scope: missing or unsupported working-tree path ${path}`);
|
|
75
|
+
const change = !before ? 'added' : !after ? 'deleted' : before.mode === after.mode && before.sha256 === after.sha256 ? 'unchanged' : before.mode.slice(0,3) !== after.mode.slice(0,3) ? 'type-changed' : 'modified';
|
|
76
|
+
return {path, change, before, after};
|
|
77
|
+
});
|
|
78
|
+
const args = [...diffArgs, ...selected.map(path => `:(literal)${path}`)];
|
|
79
|
+
const scope = {
|
|
80
|
+
version:1, mode:'working-tree', review_mode:'working-tree',
|
|
81
|
+
outcome:snapshot.some(file => file.change !== 'unchanged') ? 'changes' : 'no-diff',
|
|
82
|
+
base_sha:base, observed_head:head, selection:files ? 'explicit-files' : 'all-changes', scope_paths:selected,
|
|
83
|
+
changed_paths:snapshot.filter(file => file.change !== 'unchanged').map(file => file.path),
|
|
84
|
+
excluded_paths:changed.filter(path => !selected.includes(path)),
|
|
85
|
+
working_snapshot:snapshot, index_sha256:digest(index), inventory_sha256:digest(inventory),
|
|
86
|
+
tracked_diff_sha256:digest(git(root,args,null)), tracked_diff_command:['git',...args],
|
|
87
|
+
};
|
|
88
|
+
return {...scope, input_sha256:digest(JSON.stringify(scope))};
|
|
89
|
+
};
|
|
90
|
+
const first = capture(), second = capture();
|
|
91
|
+
if (first.input_sha256 !== second.input_sha256) throw new Error('review-scope: working-tree changed while freezing inputs; stop writers and retry');
|
|
92
|
+
return first;
|
|
93
|
+
}
|
|
8
94
|
function contained(root, path) {
|
|
9
95
|
const rel = relative(root, path);
|
|
10
96
|
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
@@ -65,10 +151,11 @@ function commit(repo, ref, field) {
|
|
|
65
151
|
export function resolveReviewScope(options) {
|
|
66
152
|
const { repo, mode } = options;
|
|
67
153
|
if (typeof repo !== 'string' || !repo || !modes.has(mode)) {
|
|
68
|
-
throw new Error('review-scope: --repo and --mode baseline|range|commit|pr|source-files are required');
|
|
154
|
+
throw new Error('review-scope: --repo and --mode baseline|range|commit|pr|source-files|working-tree are required');
|
|
69
155
|
}
|
|
70
156
|
if (mode === 'source-files') return sourceFiles(options);
|
|
71
|
-
if (
|
|
157
|
+
if (mode === 'working-tree') return workingTree(options);
|
|
158
|
+
if (options.files !== undefined) throw new Error('review-scope: --file requires source-files or working-tree mode');
|
|
72
159
|
git(repo, ['rev-parse', '--git-dir']);
|
|
73
160
|
let base, head;
|
|
74
161
|
let selectedParent = null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {execFileSync} from 'node:child_process';
|
|
4
|
+
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync} from 'node:fs';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
import {join} from 'node:path';
|
|
7
|
+
import {validateIndependentReview} from '../review-evidence.mjs';
|
|
8
|
+
import {appendReviewFixture} from './review-fixture.mjs';
|
|
9
|
+
function fixture(t) {
|
|
10
|
+
const repo=mkdtempSync(join(tmpdir(),'review-evidence-')),root=join(repo,'data/change');
|
|
11
|
+
t.after(()=>rmSync(repo,{recursive:true,force:true}));mkdirSync(root,{recursive:true});
|
|
12
|
+
const git=(...args)=>execFileSync('git',['-C',repo,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
13
|
+
git('init','-b','main');git('config','user.name','Fixture');git('config','user.email','test@example.invalid');
|
|
14
|
+
writeFileSync(join(repo,'.gitignore'),'data/\n');writeFileSync(join(repo,'value.txt'),'before');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
|
|
15
|
+
const base=git('rev-parse','HEAD');writeFileSync(join(repo,'value.txt'),'after');
|
|
16
|
+
const save=(options={})=>appendReviewFixture({repo,root,base,...options});
|
|
17
|
+
const validate=(options={})=>validateIndependentReview(root,'evidence/direct-spec.md',{repoRoot:repo,checkCurrent:true,expectedHead:git('rev-parse','HEAD'),...options});
|
|
18
|
+
return {repo,root,git,base,save,validate};
|
|
19
|
+
}
|
|
20
|
+
test('one actual independent reviewer may pass both axes in native or explicitly selected modes',t=>{
|
|
21
|
+
const f=fixture(t);
|
|
22
|
+
for(const mode of ['native','herdr','orca','cli']) {const r=f.save({mode});const before=readFileSync(r.path);assert.deepEqual(f.validate(),[]);assert.deepEqual(readFileSync(r.path),before);}
|
|
23
|
+
});
|
|
24
|
+
test('self review, unknown identity, mismatched original output and missing dispatch cannot certify completion',t=>{
|
|
25
|
+
const f=fixture(t),r=f.save();
|
|
26
|
+
for(const text of [
|
|
27
|
+
r.text.replace('Implementers: native:implementer','Implementers: native:reviewer'),
|
|
28
|
+
r.text.replaceAll('native:reviewer','unknown'),
|
|
29
|
+
r.text.replace('Result: verified','Result: pending'),
|
|
30
|
+
r.text.replace('Mode: native','Mode: herdr'),
|
|
31
|
+
r.text.replace('Axis: standards','Axis: specification'),
|
|
32
|
+
r.text.replace('| standards | native:reviewer | pass |','| standards | native:reviewer | skipped |'),
|
|
33
|
+
r.text.replace('Verification: evidence/direct-spec.md#review-receipt','Verification: evidence/direct-spec.md#review-input'),
|
|
34
|
+
r.text.replace('## Independent Review','## Self Review'),
|
|
35
|
+
]) {writeFileSync(r.path,text);assert.ok(f.validate().length,text);}
|
|
36
|
+
writeFileSync(r.path,r.text);assert.ok(f.validate({requiredImplementers:['native:merge-fixer']}).length);
|
|
37
|
+
});
|
|
38
|
+
test('changed code, sources and final HEAD invalidate review, unrelated reports do not',t=>{
|
|
39
|
+
const f=fixture(t),r=f.save();assert.deepEqual(f.validate(),[]);
|
|
40
|
+
writeFileSync(join(f.root,'another-report.md'),'New report outside code scope');assert.deepEqual(f.validate(),[]);
|
|
41
|
+
writeFileSync(join(f.repo,'value.txt'),'new fix');assert.match(f.validate().join('\n'),/drift/);
|
|
42
|
+
f.save();assert.deepEqual(f.validate(),[]);
|
|
43
|
+
writeFileSync(join(f.root,'evidence/fixture-standards.md'),'different standards');assert.match(f.validate().join('\n'),/source drift/);
|
|
44
|
+
f.save();f.git('add','value.txt');f.git('-c','commit.gpgsign=false','commit','-m','new checkpoint');assert.match(f.validate().join('\n'),/checkpoint/);
|
|
45
|
+
f.save();assert.deepEqual(f.validate(),[]);
|
|
46
|
+
});
|
|
47
|
+
test('valid old record is readable without reusing it for changed current inputs',t=>{
|
|
48
|
+
const f=fixture(t);f.save();writeFileSync(join(f.repo,'value.txt'),'later work');
|
|
49
|
+
assert.deepEqual(f.validate({checkCurrent:false}),[]);assert.ok(f.validate().length);
|
|
50
|
+
});
|
|
51
|
+
test('unsafe evidence paths, missing specification and empty change cannot fabricate approval',t=>{
|
|
52
|
+
const f=fixture(t),r=f.save();
|
|
53
|
+
writeFileSync(r.path,r.text.replace('Input: evidence/direct-spec.md#review-input','Input: ../outside'));
|
|
54
|
+
assert.ok(f.validate().length);
|
|
55
|
+
writeFileSync(r.path,r.text.replace('"kind": "specification"','"kind": "instructions"'));
|
|
56
|
+
assert.match(f.validate().join('\n'),/required review sources/);
|
|
57
|
+
writeFileSync(join(f.repo,'value.txt'),'before');f.save();assert.match(f.validate().join('\n'),/no-diff/);
|
|
58
|
+
});
|
|
59
|
+
test('owning results must match original axes and committed delivery rejects later dirty code',t=>{
|
|
60
|
+
const f=fixture(t);f.git('add','value.txt');f.git('-c','commit.gpgsign=false','commit','-m','reviewed implementation');
|
|
61
|
+
const head=f.git('rev-parse','HEAD'),r=f.save({head});
|
|
62
|
+
assert.deepEqual(f.validate({requireDelivery:true}),[]);
|
|
63
|
+
assert.match(f.validate({expectedResults:{standards:'request-changes',specification:'pass'}}).join('\n'),/differs from the owning report/);
|
|
64
|
+
writeFileSync(join(f.repo,'value.txt'),'unreviewed repair');
|
|
65
|
+
assert.deepEqual(f.validate(),[]); // C may inspect the immutable commit itself.
|
|
66
|
+
assert.match(f.validate({requireDelivery:true}).join('\n'),/unreviewed working-tree delivery/);
|
|
67
|
+
writeFileSync(join(f.repo,'value.txt'),'after');writeFileSync(join(f.repo,'new-feature.txt'),'unreviewed addition');
|
|
68
|
+
assert.match(f.validate({requireDelivery:true}).join('\n'),/new-feature/);
|
|
69
|
+
rmSync(join(f.repo,'new-feature.txt'));writeFileSync(join(f.root,'README.md'),'Outcome: completed\n');
|
|
70
|
+
assert.deepEqual(f.validate({requireDelivery:true}),[]);
|
|
71
|
+
const failed=r.text.replace('Axis: standards\nResult: pass','Axis: standards\nResult: request-changes').replace('| standards | native:reviewer | pass |','| standards | native:reviewer | request-changes |');
|
|
72
|
+
writeFileSync(r.path,failed);
|
|
73
|
+
assert.match(f.validate({requirePassed:false,expectedResults:{standards:'pass',specification:'pass'}}).join('\n'),/differs from the owning report/);
|
|
74
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Synthetic tool receipts for disposable tests only; never runtime evidence. */
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {mkdirSync,readFileSync,writeFileSync,existsSync} from 'node:fs';
|
|
4
|
+
import {dirname,join,relative} from 'node:path';
|
|
5
|
+
import {resolveReviewScope} from '../review-scope.mjs';
|
|
6
|
+
import {reviewInputDigest} from '../review-evidence.mjs';
|
|
7
|
+
import {readArtifactSectionSync} from '../../../../../lib/speculo-runtime.mjs';
|
|
8
|
+
const hash=text=>createHash('sha256').update(text).digest('hex');
|
|
9
|
+
export function appendReviewFixture({repo,root,artifact='evidence/direct-spec.md',base,head=null,files=['value.txt'],mode='native',reviewer='native:reviewer',implementers=['native:implementer'],spec=null,sourceLocators=null}) {
|
|
10
|
+
const path=join(root,artifact);mkdirSync(dirname(path),{recursive:true});
|
|
11
|
+
let prior=existsSync(path)?readFileSync(path,'utf8'):'';
|
|
12
|
+
prior=prior.split('\n<!-- synthetic-independent-review -->')[0];
|
|
13
|
+
writeFileSync(path,prior);
|
|
14
|
+
let sources;
|
|
15
|
+
if(sourceLocators) sources=sourceLocators.map(({kind,locator})=>{
|
|
16
|
+
const [file,anchor]=locator.split('#');
|
|
17
|
+
return {kind,locator,sha256:hash(anchor?readArtifactSectionSync(join(repo,file),anchor).text:readFileSync(join(repo,file),'utf8'))};
|
|
18
|
+
});
|
|
19
|
+
else {
|
|
20
|
+
const standards=join(root,'evidence/fixture-standards.md');mkdirSync(dirname(standards),{recursive:true});writeFileSync(standards,'# Fixture standards\nPreserve existing behavior and test changes.\n');
|
|
21
|
+
if(!spec) {spec=join(root,'evidence/fixture-spec.md');writeFileSync(spec,'# Fixture Spec\nAC-001: deliver the accepted behavior.\n');}
|
|
22
|
+
sources=[['standards',standards],['specification',spec]].map(([kind,file])=>({kind,locator:relative(repo,file).split('\\').join('/'),sha256:hash(readFileSync(file,'utf8'))}));
|
|
23
|
+
}
|
|
24
|
+
const scope=resolveReviewScope(head?{repo,mode:'range',base,head}:{repo,mode:'working-tree',base,files});
|
|
25
|
+
const input={scope,sources,exclusions:'Fixture-only records and unrelated inputs are outside the implementation scope.'};
|
|
26
|
+
const digest=reviewInputDigest(input);
|
|
27
|
+
const report=axis=>`<a id="${axis}-output"></a>\n## Original ${axis} output\nReviewer: ${reviewer}\nAxis: ${axis}\nResult: pass\nInput-SHA256: ${digest}\nObserved: synthetic fixture reviewer inspected the fixed inputs; no blocking findings.\n`;
|
|
28
|
+
const text=`\n<!-- synthetic-independent-review -->\n<a id="review-input"></a>\n## Review Input\n\n\x60\x60\x60json\n${JSON.stringify(input,null,2)}\n\x60\x60\x60\n\n${report('standards')}\n${report('specification')}\n<a id="review-receipt"></a>\n## Original dispatch and verification\nVerified-By: native:lead\nMode: ${mode}\nReviewers: ${reviewer}\nInput-SHA256: ${digest}\nResult: verified\nObserved: synthetic fixture dispatch returned the separate reviewer handle and both original outputs.\n\n## Independent Review\nInput: ${artifact}#review-input\nImplementers: ${implementers.join(', ')}\nMode: ${mode}\nVerified-By: native:lead\nVerification: ${artifact}#review-receipt\n\n| Axis | Reviewer | Result | Report |\n|---|---|---|---|\n| standards | ${reviewer} | pass | ${artifact}#standards-output |\n| specification | ${reviewer} | pass | ${artifact}#specification-output |\n`;
|
|
29
|
+
writeFileSync(path,prior+text);
|
|
30
|
+
return {input,digest,path,text:prior+text};
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {execFileSync} from 'node:child_process';
|
|
4
|
+
import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,rmSync,renameSync,chmodSync,symlinkSync} from 'node:fs';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
import {join} from 'node:path';
|
|
7
|
+
import {resolveReviewScope} from '../review-scope.mjs';
|
|
8
|
+
|
|
9
|
+
function fixture(t) {
|
|
10
|
+
const root=mkdtempSync(join(tmpdir(),'review-working-'));
|
|
11
|
+
t.after(()=>rmSync(root,{recursive:true,force:true}));
|
|
12
|
+
const git=(...args)=>execFileSync('git',['-C',root,...args],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
13
|
+
git('init','-b','main');git('config','user.name','Fixture');git('config','user.email','test@example.invalid');
|
|
14
|
+
git('config','core.autocrlf','false');
|
|
15
|
+
for(const path of ['one.txt','removed.txt','renamed.txt']) writeFileSync(join(root,path),'original\n');
|
|
16
|
+
writeFileSync(join(root,'.gitignore'),'ignored/\n');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
|
|
17
|
+
const base=git('rev-parse','HEAD');
|
|
18
|
+
return {root,git,base,scope:(extra={})=>resolveReviewScope({repo:root,mode:'working-tree',base,...extra})};
|
|
19
|
+
}
|
|
20
|
+
test('working-tree freezes staged, unstaged, added, deleted, renamed and mode changes without writes',t=>{
|
|
21
|
+
const f=fixture(t);
|
|
22
|
+
writeFileSync(join(f.root,'one.txt'),'staged\n');f.git('add','one.txt');writeFileSync(join(f.root,'one.txt'),'final\n');
|
|
23
|
+
rmSync(join(f.root,'removed.txt'));renameSync(join(f.root,'renamed.txt'),join(f.root,'new-name.txt'));
|
|
24
|
+
writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,7]));chmodSync(join(f.root,'one.txt'),0o755);
|
|
25
|
+
mkdirSync(join(f.root,'ignored'));writeFileSync(join(f.root,'ignored/not-selected'),'ignored');
|
|
26
|
+
const before=readFileSync(join(f.root,'.git/index')), status=f.git('status','--porcelain');
|
|
27
|
+
const scope=f.scope();
|
|
28
|
+
assert.equal(scope.outcome,'changes');assert.equal(scope.observed_head,f.base);assert.equal(scope.head_sha,undefined);
|
|
29
|
+
assert.deepEqual(scope.changed_paths,['new-name.txt','new.bin','one.txt','removed.txt','renamed.txt']);
|
|
30
|
+
assert.equal(scope.working_snapshot.find(x=>x.path==='one.txt').after.mode,process.platform === 'win32' ? '100644' : '100755');
|
|
31
|
+
assert.equal(scope.working_snapshot.find(x=>x.path==='removed.txt').after,null);
|
|
32
|
+
assert.equal(scope.working_snapshot.find(x=>x.path==='new.bin').after.bytes,3);
|
|
33
|
+
assert.deepEqual(f.scope(),scope);assert.equal(f.git('status','--porcelain'),status);assert.deepEqual(readFileSync(join(f.root,'.git/index')),before);
|
|
34
|
+
writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,8]));assert.notEqual(f.scope().input_sha256,scope.input_sha256);
|
|
35
|
+
});
|
|
36
|
+
test('explicit scopes are literal, expose exclusions and retain deleted files',t=>{
|
|
37
|
+
const f=fixture(t), path='literal [abc] $(touch unsafe).txt';
|
|
38
|
+
writeFileSync(join(f.root,path),'literal');rmSync(join(f.root,'removed.txt'));writeFileSync(join(f.root,'one.txt'),'changed');
|
|
39
|
+
const scope=f.scope({files:[path,'removed.txt']});
|
|
40
|
+
assert.deepEqual(scope.changed_paths,[path,'removed.txt']);assert.deepEqual(scope.excluded_paths,['one.txt']);
|
|
41
|
+
assert.equal(scope.selection,'explicit-files');
|
|
42
|
+
for(const files of [[],['../outside'],['.git/config'],['one.txt','one.txt'],['missing']]) assert.throws(()=>f.scope({files}));
|
|
43
|
+
for(const extra of [{head:'HEAD'},{commit:'HEAD'},{parent:1}]) assert.throws(()=>f.scope(extra));
|
|
44
|
+
assert.throws(()=>f.scope({base:'missing'}));
|
|
45
|
+
});
|
|
46
|
+
test('links are read as links and directory links never disclose outside bytes',t=>{
|
|
47
|
+
const f=fixture(t), outside=mkdtempSync(join(tmpdir(),'review-outside-'));
|
|
48
|
+
t.after(()=>rmSync(outside,{recursive:true,force:true}));writeFileSync(join(outside,'secret'),'sensitive');
|
|
49
|
+
symlinkSync(join(outside,'secret'),join(f.root,'link'));
|
|
50
|
+
const scope=f.scope({files:['link']});assert.equal(scope.working_snapshot[0].after.kind,'symlink');
|
|
51
|
+
symlinkSync(outside,join(f.root,'directory'),process.platform === 'win32' ? 'junction' : 'dir');
|
|
52
|
+
assert.throws(()=>f.scope({files:['directory/secret']}),/symlink directory/);
|
|
53
|
+
});
|
|
54
|
+
test('unmerged index and borrowed parent repository are rejected',t=>{
|
|
55
|
+
const f=fixture(t);f.git('checkout','-b','other');writeFileSync(join(f.root,'one.txt'),'other\n');f.git('commit','-am','other');
|
|
56
|
+
f.git('checkout','main');writeFileSync(join(f.root,'one.txt'),'main\n');f.git('commit','-am','main');
|
|
57
|
+
assert.throws(()=>f.git('merge','other'));assert.throws(()=>f.scope(),/unresolved index/);
|
|
58
|
+
f.git('merge','--abort');mkdirSync(join(f.root,'nested'));
|
|
59
|
+
assert.throws(()=>resolveReviewScope({repo:join(f.root,'nested'),mode:'working-tree',base:f.base}),/actual project Git root/);
|
|
60
|
+
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { validateChangeClosure, readChangeOwner } from "./change-artifacts.mjs";
|
|
32
32
|
import { validateRecord, validateRecords } from "./delivery-artifacts.mjs";
|
|
33
33
|
import { reviewScopeCLI } from "./review-scope.mjs";
|
|
34
|
+
import { hasIndependentReview, validateIndependentReview } from "./review-evidence.mjs";
|
|
34
35
|
import { ENGINEERING_KINDS, validateEngineeringArtifacts, validateEngineeringPackage } from "./engineering-evidence.mjs";
|
|
35
36
|
|
|
36
37
|
const DOMAIN_SCHEMA_VERSION = 3;
|
|
@@ -202,15 +203,6 @@ const REQUIRED_TICKET_KEYS = new Set([
|
|
|
202
203
|
"shared_paths",
|
|
203
204
|
"shared_path_owners",
|
|
204
205
|
]);
|
|
205
|
-
const REQUIRED_READY_TICKET_SECTIONS = new Set([
|
|
206
|
-
"## 1. 战略与来源",
|
|
207
|
-
"## 2. 决策状态",
|
|
208
|
-
"## 3. 范围边界",
|
|
209
|
-
"## 4. 要构建什么",
|
|
210
|
-
"## 7. 路径访问契约",
|
|
211
|
-
"## 8. 验证矩阵",
|
|
212
|
-
"## 10. 验收标准",
|
|
213
|
-
]);
|
|
214
206
|
const REQUIRED_GOAL_PLAN_SECTIONS = new Set([
|
|
215
207
|
"## 1. Outcome and Authority",
|
|
216
208
|
"## 2. Execution Graph",
|
|
@@ -334,6 +326,20 @@ function sectionBody(body, heading) {
|
|
|
334
326
|
return (next ? tail.slice(0, next.index) : tail).trim();
|
|
335
327
|
}
|
|
336
328
|
|
|
329
|
+
// Accept the published long templates and the concise headings without making
|
|
330
|
+
// numbering, repeated instructions or a fixed count of implementation steps a gate.
|
|
331
|
+
function semanticSection(body, names) {
|
|
332
|
+
const lines = runtime.markdownBodyLines(body);
|
|
333
|
+
const matches = lines.filter(line => {
|
|
334
|
+
const match = /^(#{2,3})\s+(?:\d+\.\s*)?(.+)$/.exec(line.text);
|
|
335
|
+
return match && names.includes(match[2]);
|
|
336
|
+
});
|
|
337
|
+
if (matches.length !== 1) return "";
|
|
338
|
+
const start = matches[0], level = /^#+/.exec(start.text)[0].length;
|
|
339
|
+
const end = lines.find(line => line.line > start.line && /^#{1,3}\s/.test(line.text) && /^#+/.exec(line.text)[0].length <= level);
|
|
340
|
+
return body.split(/\r?\n/).slice(start.line, end ? end.line-1 : undefined).join("\n").trim();
|
|
341
|
+
}
|
|
342
|
+
|
|
337
343
|
function stripPathTag(value) {
|
|
338
344
|
const match = /^<Path>([^<]+)<\/Path>$/.exec(String(value).trim());
|
|
339
345
|
return match ? match[1].trim() : null;
|
|
@@ -1460,12 +1466,13 @@ function validateDiagnosis(path, expectedChange, errors) {
|
|
|
1460
1466
|
return { path, meta, body };
|
|
1461
1467
|
}
|
|
1462
1468
|
|
|
1463
|
-
function validateReviews(change, required, errors) {
|
|
1469
|
+
function validateReviews(change, required, errors, repoRoot = null) {
|
|
1464
1470
|
const root = join(change, "reviews");
|
|
1465
1471
|
const paths = isDirectory(root)
|
|
1466
1472
|
? readdirSync(root).filter((name) => /^CR-\d{3,}\.md$/.test(name)).sort().map((name) => join(root, name))
|
|
1467
1473
|
: [];
|
|
1468
1474
|
if (required && !paths.length) errors.push("review stage requires a CR-###.md report");
|
|
1475
|
+
const latest = [...paths].sort((a,b) => Number(basename(a).match(/\d+/)[0])-Number(basename(b).match(/\d+/)[0])).at(-1);
|
|
1469
1476
|
for (const path of paths) {
|
|
1470
1477
|
const { meta, body } = parseFrontmatter(path);
|
|
1471
1478
|
if (meta.schema_version !== 1 || meta.artifact !== "code-review") {
|
|
@@ -1483,6 +1490,12 @@ function validateReviews(change, required, errors) {
|
|
|
1483
1490
|
for (const heading of ["## Fixed Input", "## 标准", "## 规范", "## Summary"]) {
|
|
1484
1491
|
if (!body.includes(heading)) errors.push(`${basename(path)}: missing '${heading}'`);
|
|
1485
1492
|
}
|
|
1493
|
+
// Historical CRs remain readable. Only the current review or an explicitly
|
|
1494
|
+
// supplied new record is checked against the independent execution contract.
|
|
1495
|
+
if (required && path === latest || hasIndependentReview(path)) {
|
|
1496
|
+
errors.push(...validateIndependentReview(change, toPosix(relative(change,path)), {repoRoot,requirePassed:meta.status === 'approved',expectedResults:{standards:meta.standards_result,specification:meta.specification_result},expectedHead:meta.head,checkCurrent:required && path === latest}));
|
|
1497
|
+
if (meta.status === 'approved' && (meta.standards_result !== 'pass' || meta.specification_result !== 'pass')) errors.push(`${basename(path)}: approved requires both review axes to pass`);
|
|
1498
|
+
}
|
|
1486
1499
|
}
|
|
1487
1500
|
return paths;
|
|
1488
1501
|
}
|
|
@@ -1692,18 +1705,12 @@ function validateSpec(path, errors, warnings) {
|
|
|
1692
1705
|
const sources = requireList(meta, "sources", label, errors);
|
|
1693
1706
|
if (!sources.length) errors.push(`${label}: sources must contain at least one source`);
|
|
1694
1707
|
if (meta.ready_for_tickets === true) {
|
|
1695
|
-
const unresolved =
|
|
1708
|
+
const unresolved = semanticSection(body, ["未决问题"]);
|
|
1696
1709
|
if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
|
|
1697
1710
|
errors.push(`${label}: ready Spec has unresolved high-impact questions`);
|
|
1698
1711
|
}
|
|
1699
|
-
for (const heading of [
|
|
1700
|
-
|
|
1701
|
-
"## 2. 解决方案与外部行为",
|
|
1702
|
-
"## 4. 验收合同",
|
|
1703
|
-
"## 5. 范围",
|
|
1704
|
-
"## 9. 验证策略",
|
|
1705
|
-
]) {
|
|
1706
|
-
if (!body.includes(heading)) errors.push(`${label}: ready Spec missing '${heading}'`);
|
|
1712
|
+
for (const heading of ["问题与目标", "解决方案与外部行为", "验收合同", "范围", "验证策略"]) {
|
|
1713
|
+
if (!semanticSection(body,[heading])) errors.push(`${label}: ready Spec missing '${heading}' content`);
|
|
1707
1714
|
}
|
|
1708
1715
|
}
|
|
1709
1716
|
return { path, meta, body };
|
|
@@ -2181,32 +2188,22 @@ function validateTicket(path, errors) {
|
|
|
2181
2188
|
if (!new Set(["ready", "in_progress", "review", "done"]).has(meta.status)) {
|
|
2182
2189
|
errors.push(`${basename(path)}: ready=true conflicts with status=${meta.status}`);
|
|
2183
2190
|
}
|
|
2184
|
-
for (const
|
|
2185
|
-
if (!body
|
|
2191
|
+
for (const names of [["目标与来源","战略与来源"],["行为与范围","要构建什么"],["验收标准"],["验证方法","验证矩阵"]]) {
|
|
2192
|
+
if (!semanticSection(body,names)) errors.push(`${basename(path)}: Ready Ticket missing '${names[0]}' content`);
|
|
2186
2193
|
}
|
|
2187
|
-
const unresolved =
|
|
2194
|
+
const unresolved = semanticSection(body, ["未决问题"]);
|
|
2188
2195
|
if (unresolved && !/^无[。.]?$/.test(unresolved.trim())) {
|
|
2189
2196
|
errors.push(`${basename(path)}: Ready Ticket has unresolved questions`);
|
|
2190
2197
|
}
|
|
2191
2198
|
if (!lists.writable_paths.length) errors.push(`${basename(path)}: Ready implementation Ticket requires writable_paths`);
|
|
2192
|
-
|
|
2193
|
-
for (const heading of ["## 5. 实现契约", "## 6. 执行路线"]) {
|
|
2194
|
-
if (!body.includes(heading)) {
|
|
2195
|
-
errors.push(`${basename(path)}: ${meta.planning_depth} Ticket missing '${heading}'`);
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
if (meta.planning_depth === "deep" && !body.includes("## 9. 发布、迁移与恢复")) {
|
|
2200
|
-
errors.push(`${basename(path)}: Deep Ticket missing release/migration/recovery`);
|
|
2201
|
-
}
|
|
2202
|
-
const verification = sectionBody(body, "## 8. 验证矩阵");
|
|
2199
|
+
const verification = semanticSection(body, ["验证方法","验证矩阵"]);
|
|
2203
2200
|
if (!verification || !verification.includes("|")) {
|
|
2204
2201
|
errors.push(`${basename(path)}: Ready Ticket has no usable verification matrix`);
|
|
2205
2202
|
}
|
|
2206
2203
|
if (!verification.includes("E2E disposition") || !/(current-workspace|source-worktree|parent-candidate|direct-parent)/.test(verification)) {
|
|
2207
2204
|
errors.push(`${basename(path)}: Ready Ticket must define E2E disposition and an explicit execution environment`);
|
|
2208
2205
|
}
|
|
2209
|
-
const acceptance =
|
|
2206
|
+
const acceptance = semanticSection(body, ["验收标准"]).toLowerCase();
|
|
2210
2207
|
if (!acceptance.includes("- [ ]") && !acceptance.includes("- [x]")) {
|
|
2211
2208
|
errors.push(`${basename(path)}: Ready Ticket has no acceptance checklist`);
|
|
2212
2209
|
}
|
|
@@ -3099,7 +3096,7 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
|
|
|
3099
3096
|
if(archive?.archive_state === 'moved') warnings.push('archive moved and checked, but is not sealed; finish the original A8 receipt');
|
|
3100
3097
|
return { errors, warnings, ...(archive ? {archive} : {}) };
|
|
3101
3098
|
}
|
|
3102
|
-
|
|
3099
|
+
const parentImplementation = isParentImplementation ? validateParentImplementation(change, changeStatus, stage, errors, warnings, repoRoot, options) : null;
|
|
3103
3100
|
errors.push(...validateInitiative(change));
|
|
3104
3101
|
if (isFile(join(change, "source-issue.md"))) {
|
|
3105
3102
|
errors.push("obsolete source-issue.md is forbidden; use source.md without compatibility fallback");
|
|
@@ -3127,7 +3124,7 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
|
|
|
3127
3124
|
if (isFile(diagnosisPath) || stage === "diagnosis") {
|
|
3128
3125
|
validateDiagnosis(diagnosisPath, basename(change), errors);
|
|
3129
3126
|
}
|
|
3130
|
-
validateReviews(change, stage === "review", errors);
|
|
3127
|
+
validateReviews(change, stage === "review", errors, workspace.context.projectRoot);
|
|
3131
3128
|
validatePrototypes(change, stage === "prototype", errors, prototypeId, new Set(["spec", "tickets", "goal-plan", "implement", "review", "complete"]).has(stage), workspace.context);
|
|
3132
3129
|
if (new Set(["tickets", "goal-plan", "implement", "review", "complete"]).has(stage)) runDesignCheck("--handoff", change, errors, workspace.context);
|
|
3133
3130
|
validateChangeLearning(change, stage === "learn", errors);
|
|
@@ -3266,6 +3263,43 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
|
|
|
3266
3263
|
|
|
3267
3264
|
if (stage === "complete" && spec && !ticketMode && !isParentImplementation && !retroOnly) errors.push(...validateAcceptanceEvidence(change,"evidence/direct-spec.md",[...new Set(spec.body.match(/\bAC-\d+\b/g) ?? [])]));
|
|
3268
3265
|
|
|
3266
|
+
// New completion/archival consumes actual review evidence. Historical records
|
|
3267
|
+
// remain readable outside these gates; archived validation returned earlier.
|
|
3268
|
+
let nonImplementation = false;
|
|
3269
|
+
if (stage === 'complete' && !spec && !ticketMode && !simpleGoal && !goalPlan && !isParentImplementation && !changeStatus?.worktrees?.length && !isDirectory(join(change,'evidence'))) {
|
|
3270
|
+
try {
|
|
3271
|
+
const owner = readChangeOwner(workspace.context,basename(change));
|
|
3272
|
+
if (owner.work === 'specdev/learn' && isFile(join(change,'learning','index.md'))) {
|
|
3273
|
+
validateChangeLearning(change,true,errors);
|
|
3274
|
+
nonImplementation = true;
|
|
3275
|
+
} else if (owner.work === 'specdev/triage' && source && triage?.meta.ready_for_implementation === false) nonImplementation = true;
|
|
3276
|
+
} catch(error) { errors.push(error.message); }
|
|
3277
|
+
}
|
|
3278
|
+
if (stage === 'complete' && changeStatus?.location.kind === 'active' && changeStatus?.outcome !== 'cancelled' && !retroOnly && !nonImplementation) {
|
|
3279
|
+
const units = [...tickets.values()].filter(ticket => ticket.meta.status !== 'cancelled');
|
|
3280
|
+
const finalArtifact = isParentImplementation ? 'evidence/implementation-orchestration.md'
|
|
3281
|
+
: units.length > 1 ? 'evidence/final-review.md'
|
|
3282
|
+
: units.length === 1 ? `evidence/${units[0].meta.id}.md`
|
|
3283
|
+
: lightweight ? 'README.md' : 'evidence/direct-spec.md';
|
|
3284
|
+
try {
|
|
3285
|
+
const head = execFileSync('git',['-C',workspace.context.projectRoot,'rev-parse','--verify','HEAD^{commit}'],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
|
|
3286
|
+
const executions = [...(changeStatus.worktrees ?? []), ...[...(parentImplementation?.memberStatuses?.values() ?? [])].flatMap(status=>status.worktrees ?? [])];
|
|
3287
|
+
const owners = executions.flatMap(record => [record.implementation_owner, ...(record.integration?.conflict_paths?.length ? [record.integration_owner] : [])]).filter(value => typeof value === 'string' && /^[a-z][a-z0-9-]*:\S+$/i.test(value));
|
|
3288
|
+
const bases = [...new Set(executions.map(record=>record.base_sha).filter(value=>/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(value ?? '')))];
|
|
3289
|
+
const expectedBase = bases.length > 1 ? execFileSync('git',['-C',workspace.context.projectRoot,'merge-base','--octopus',...bases],{encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim() : bases[0] ?? null;
|
|
3290
|
+
const sourceLocator = path => {
|
|
3291
|
+
const snapshot = workspace.context.readOnlySnapshot;
|
|
3292
|
+
const original = snapshot ? join(snapshot.sourceContext.specdevRoot,relative(workspace.context.specdevRoot,path)) : path;
|
|
3293
|
+
return toPosix(relative(workspace.context.projectRoot,original));
|
|
3294
|
+
};
|
|
3295
|
+
const specLocator = sourceLocator(spec ? spec.path : join(change,'README.md'));
|
|
3296
|
+
const requiredSpecifications = isParentImplementation
|
|
3297
|
+
? [sourceLocator(join(change,'implementation-plan.md')),sourceLocator(join(change,'implementation-map.md')),...new Set([...(parentImplementation?.ticketByTask?.values() ?? [])].map(unit=>sourceLocator(join(unit.memberRoot,'spec.md'))))]
|
|
3298
|
+
: spec ? [specLocator] : [`${specLocator}#scope`,`${specLocator}#acceptance`];
|
|
3299
|
+
errors.push(...validateIndependentReview(change,finalArtifact,{repoRoot:workspace.context.projectRoot,expectedHead:head,expectedBase,requiredImplementers:owners,requiredSpecifications,checkCurrent:true,requireDelivery:true}));
|
|
3300
|
+
} catch(error) { errors.push(`independent-review: final delivery checkpoint unavailable: ${error.message}`); }
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3269
3303
|
if (spec && ticketMode) {
|
|
3270
3304
|
const declaredContracts = new Set(spec.body.match(/\bAC-\d+\b/g) ?? []);
|
|
3271
3305
|
const coveredContracts = new Set(
|
|
@@ -3351,6 +3385,14 @@ function validateChange(change, stage = null, repoRoot = null, engineeringKind =
|
|
|
3351
3385
|
continue;
|
|
3352
3386
|
}
|
|
3353
3387
|
const text = readText(evidence);
|
|
3388
|
+
if (stage === 'complete' && changeStatus?.location.kind === 'active' || hasIndependentReview(evidence)) {
|
|
3389
|
+
const execution = changeStatus?.worktrees?.find(entry => entry.ticket_id === ticketId);
|
|
3390
|
+
errors.push(...validateIndependentReview(change, `evidence/${ticketId}.md`, {
|
|
3391
|
+
repoRoot:workspace.context.projectRoot,
|
|
3392
|
+
expectedHead:execution?.integration?.result_sha ?? execution?.source_checkpoint ?? null,
|
|
3393
|
+
checkCurrent:false,
|
|
3394
|
+
}));
|
|
3395
|
+
}
|
|
3354
3396
|
const currentWorkspace = changeStatus?.worktrees?.find(entry => entry.ticket_id === ticketId)?.workspace_ref === "current";
|
|
3355
3397
|
const requiredHeadings = [
|
|
3356
3398
|
"## 2. Lead Dispatch And Candidate Return",
|
|
@@ -3471,7 +3513,7 @@ function printResults(errors, warnings) {
|
|
|
3471
3513
|
}
|
|
3472
3514
|
|
|
3473
3515
|
function usage() {
|
|
3474
|
-
console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--sealed (archived only)] [--prototype-id UI-NNN|LOGIC-NNN] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr|source-files> [comparison options or repeated --file <relative-path>] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
|
|
3516
|
+
console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--sealed (archived only)] [--prototype-id UI-NNN|LOGIC-NNN] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr|source-files|working-tree> [comparison options or repeated --file <relative-path>] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
|
|
3475
3517
|
return 2;
|
|
3476
3518
|
}
|
|
3477
3519
|
|