@namewta/speculo 1.0.22 → 1.0.24
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 +6 -3
- package/dist/src/media-state.d.ts +2 -0
- package/dist/src/media-state.js +52 -0
- package/dist/src/media-state.js.map +1 -0
- package/dist/src/structured.js +2 -0
- package/dist/src/structured.js.map +1 -1
- package/package.json +5 -4
- package/template/canonical/canonical-learning-lesson.md +116 -0
- package/template/canonical/canonical-learning-question.md +185 -0
- package/template/canonical/canonical-media-content.md +50 -0
- package/template/canonical/canonical-media-operations.md +53 -0
- package/template/canonical/canonical-media-video.md +48 -0
- package/template/canonical/canonical-media-visual.md +94 -0
- package/template/canonical/canonical-specdev-goal-plan.md +287 -0
- package/template/canonical/canonical-specdev-grill-with-docs.md +229 -0
- package/template/canonical/canonical-specdev-spec.md +277 -0
- package/template/canonical/canonical-specdev-tickets.md +287 -0
- package/template/canonical/canonical-specdev-wayfinder.md +8 -0
- package/template/workflows/learning/G-goal/probe-template.md +4 -2
- package/template/workflows/learning/G-goal/references/coverage-bar.md +4 -0
- package/template/workflows/learning/INDEX.md +2 -2
- package/template/workflows/learning/L-lesson/L-lesson.md +5 -1
- package/template/workflows/learning/L-lesson/lesson-template.md +4 -0
- package/template/workflows/learning/Q-question/Q-question.md +25 -33
- package/template/workflows/learning/Q-question/exploration-template.md +28 -0
- package/template/workflows/learning/Q-question/inquiry-template.md +4 -0
- package/template/workflows/learning/Q-question/question-map-template.json +4 -0
- package/template/workflows/learning/Q-question/references/exploration-mode.md +35 -0
- package/template/workflows/learning/Q-question/references/tutor-mode.md +31 -0
- package/template/workflows/learning/Q-question/tools/validate-exploration.mjs +190 -0
- package/template/workflows/learning/README.md +10 -4
- package/template/workflows/learning/common/rules/artifact-layouts.md +5 -0
- package/template/workflows/learning/common/rules/inquiry-depth-policy.md +31 -0
- package/template/workflows/learning/common/rules/lesson-contract.md +9 -1
- package/template/workflows/learning/common/rules/portable-learning-runtime.md +95 -0
- package/template/workflows/learning/common/rules/production-inquiry.md +24 -0
- package/template/workflows/learning/common/rules/question-map-contract.md +28 -0
- package/template/workflows/learning/common/rules/questioning-policy.md +12 -2
- package/template/workflows/learning/common/rules/teaching-policy.md +4 -0
- package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +2 -2
- package/template/workflows/media/B-brief/B-brief.md +18 -0
- package/template/workflows/media/B-brief/brief-template.json +32 -0
- package/template/workflows/media/D-deconstruct/D-deconstruct.md +18 -0
- package/template/workflows/media/H-hand-drawn/H-hand-drawn.md +18 -0
- package/template/workflows/media/H-hand-drawn/production.md +9 -0
- package/template/workflows/media/H-hand-drawn/style-catalog.json +226 -0
- package/template/workflows/media/I-init-setup/I-init-setup.md +18 -0
- package/template/workflows/media/INDEX.md +20 -0
- package/template/workflows/media/P-publish-draft/P-publish-draft.md +18 -0
- package/template/workflows/media/P-publish-draft/draft-request-template.json +14 -0
- package/template/workflows/media/R-retro/R-retro.md +18 -0
- package/template/workflows/media/R-retro/retro-template.md +13 -0
- package/template/workflows/media/README.md +76 -0
- package/template/workflows/media/V-video/V-video.md +18 -0
- package/template/workflows/media/V-video/production.md +9 -0
- package/template/workflows/media/V-video/render_preview.py +168 -0
- package/template/workflows/media/V-video/review-template.json +27 -0
- package/template/workflows/media/V-video/shot-plan-template.json +18 -0
- package/template/workflows/media/_state/archive/.gitkeep +1 -0
- package/template/workflows/media/_state/changes/.gitkeep +1 -0
- package/template/workflows/media/_state/context/INDEX.md +6 -0
- package/template/workflows/media/_state/status.json +6 -0
- package/template/workflows/media/common/rules/activation-and-memory.md +7 -0
- package/template/workflows/media/common/rules/artifact-contract.md +17 -0
- package/template/workflows/media/common/rules/content-method.md +18 -0
- package/template/workflows/media/common/rules/external-skills.md +33 -0
- package/template/workflows/media/common/rules/operations-method.md +21 -0
- package/template/workflows/media/common/rules/safety-and-recovery.md +15 -0
- package/template/workflows/media/common/rules/video-method.md +16 -0
- package/template/workflows/media/common/rules/visual-method.md +16 -0
- package/template/workflows/media/common/schemas/change-status.schema.json +75 -0
- package/template/workflows/media/common/schemas/status.schema.json +35 -0
- package/template/workflows/media/common/tests/media-tools.test.mjs +91 -0
- package/template/workflows/media/common/tools/media-tools.mjs +235 -0
- package/template/workflows/media/common/tools/validate-media.mjs +56 -0
- package/template/workflows/media/manifest.json +107 -0
- package/template/workflows/media/runtime-contract.json +10 -0
- package/template/workflows/specdev/A-archive-and-consolidate/A-archive-and-consolidate.md +6 -0
- package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +4 -0
- package/template/workflows/specdev/C-code-review/C-code-review.md +7 -5
- package/template/workflows/specdev/C-code-review/code-review-template.md +8 -1
- package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +10 -4
- package/template/workflows/specdev/D-diagnose-bugs/diagnosis-template.md +4 -0
- package/template/workflows/specdev/D-diagnose-bugs/feedback-loop.md +3 -1
- package/template/workflows/specdev/D-diagnose-bugs/hypothesis-and-instrumentation.md +4 -0
- package/template/workflows/specdev/D-diagnose-bugs/references/incident-evidence.md +21 -0
- package/template/workflows/specdev/G-grill-with-docs/G-grill-with-docs.md +6 -0
- package/template/workflows/specdev/G-grill-with-docs/grilling-protocol.md +4 -0
- package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +4 -0
- package/template/workflows/specdev/I-implement/I-implement.md +8 -2
- package/template/workflows/specdev/I-implement/evidence-template.md +4 -0
- package/template/workflows/specdev/I-implement/execution-preflight.md +4 -0
- package/template/workflows/specdev/I-implement/references/documentation-procedure.md +21 -0
- package/template/workflows/specdev/I-implement/references/implementation-procedure.md +7 -1
- package/template/workflows/specdev/I-implement/tdd-mocking.md +4 -0
- package/template/workflows/specdev/I-implement/tdd-rules.md +4 -0
- package/template/workflows/specdev/I-implement/tdd-test-design.md +4 -0
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +4 -0
- package/template/workflows/specdev/INDEX.md +4 -0
- package/template/workflows/specdev/L-learn-change/L-learn-change.md +4 -0
- package/template/workflows/specdev/L-learn-change/references/lesson-format.md +4 -0
- package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +6 -0
- package/template/workflows/specdev/P-goal-plan/completion-control.md +4 -0
- package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +4 -0
- package/template/workflows/specdev/P-goal-plan/planning-modes.md +4 -0
- package/template/workflows/specdev/P-prototype/P-prototype.md +4 -0
- package/template/workflows/specdev/P-prototype/generate-design-package.md +4 -0
- package/template/workflows/specdev/P-prototype/style-selection-protocol.md +4 -0
- package/template/workflows/specdev/R-retro/R-retro.md +4 -0
- package/template/workflows/specdev/R-retro/retro-template.md +4 -0
- package/template/workflows/specdev/R-review-architecture/R-review-architecture.md +14 -4
- package/template/workflows/specdev/R-review-architecture/architecture-review-template.md +4 -0
- package/template/workflows/specdev/R-review-architecture/references/performance-review.md +27 -0
- package/template/workflows/specdev/R-review-architecture/references/selected-design.md +4 -0
- package/template/workflows/specdev/R-review-architecture/review-rubric.md +4 -0
- package/template/workflows/specdev/README.md +14 -6
- package/template/workflows/specdev/S-spec/S-spec.md +4 -0
- package/template/workflows/specdev/S-spec/spec-template.md +4 -0
- package/template/workflows/specdev/T-tickets/T-tickets.md +6 -0
- package/template/workflows/specdev/T-tickets/decomposition-rules.md +4 -0
- package/template/workflows/specdev/T-tickets/ticket-readiness.md +4 -0
- package/template/workflows/specdev/T-tickets/ticket-template.md +4 -0
- package/template/workflows/specdev/T-triage/T-triage.md +7 -1
- package/template/workflows/specdev/T-triage/ci-security-protocol.md +4 -0
- package/template/workflows/specdev/T-triage/pr-delivery-protocol.md +4 -0
- package/template/workflows/specdev/T-triage/release-preflight.md +4 -0
- package/template/workflows/specdev/T-triage/release-protocol.md +4 -0
- package/template/workflows/specdev/T-triage/release-recovery.md +4 -0
- package/template/workflows/specdev/W-wayfinder/W-wayfinder.md +4 -0
- package/template/workflows/specdev/W-wayfinder/investigation-ticket-template.md +4 -0
- package/template/workflows/specdev/common/README.md +8 -0
- package/template/workflows/specdev/common/rules/agent-experience.md +23 -0
- package/template/workflows/specdev/common/rules/artifact-contract.md +6 -0
- package/template/workflows/specdev/common/rules/codebase-design.md +16 -14
- package/template/workflows/specdev/common/rules/documentation-standard.md +29 -0
- package/template/workflows/specdev/common/rules/engineering-evidence.md +31 -0
- package/template/workflows/specdev/common/rules/error-handling.md +36 -0
- package/template/workflows/specdev/common/rules/evidence-and-verification.md +16 -0
- package/template/workflows/specdev/common/rules/test-reliability.md +33 -0
- package/template/workflows/specdev/common/rules/workflow-routing.md +13 -0
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +6 -0
- package/template/workflows/specdev/common/skills/code-review/SKILL.md +4 -2
- package/template/workflows/specdev/common/skills/code-review/references/result-reuse.md +1 -1
- package/template/workflows/specdev/common/tools/README.md +12 -2
- package/template/workflows/specdev/common/tools/engineering-evidence.mjs +150 -0
- package/template/workflows/specdev/common/tools/review-scope.mjs +82 -0
- package/template/workflows/specdev/common/tools/tests/engineering.test.mjs +267 -0
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +27 -5
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Optional, versioned observations in existing artifacts; not a second task state. */
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join, relative, isAbsolute } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const ENGINEERING_KINDS = new Set(['performance', 'documentation', 'incident']);
|
|
6
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
8
|
+
const strings = value => Array.isArray(value) && value.length > 0 && value.every(text);
|
|
9
|
+
const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
10
|
+
const samples = value => Array.isArray(value) && value.length > 0 && value.every(x => typeof x === 'number' && Number.isFinite(x) && x >= 0);
|
|
11
|
+
const mean = values => values.reduce((average, value, i) => average * (i / (i + 1)) + value / (i + 1), 0);
|
|
12
|
+
|
|
13
|
+
/** Parse only explicitly labelled fences outside other fenced examples. */
|
|
14
|
+
export function engineeringRecords(body) {
|
|
15
|
+
const records = [], errors = [];
|
|
16
|
+
let fence = null, lines = [], selected = false;
|
|
17
|
+
for (const line of body.split(/\r?\n/)) {
|
|
18
|
+
if (!fence) {
|
|
19
|
+
const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
20
|
+
if (!opening) continue;
|
|
21
|
+
fence = opening[1];
|
|
22
|
+
selected = opening[2].trim() === 'json specdev-engineering-evidence';
|
|
23
|
+
lines = [];
|
|
24
|
+
} else if (new RegExp(`^ {0,3}${fence[0]}{${fence.length},}\\s*$`).test(line)) {
|
|
25
|
+
if (selected) {
|
|
26
|
+
try { records.push(JSON.parse(lines.join('\n'))); }
|
|
27
|
+
catch { errors.push('engineering/fence: expected one valid JSON object'); }
|
|
28
|
+
}
|
|
29
|
+
fence = null;
|
|
30
|
+
} else lines.push(line);
|
|
31
|
+
}
|
|
32
|
+
if (fence && selected) errors.push('engineering/fence: unclosed evidence fence');
|
|
33
|
+
return { records, errors };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Reject internally inconsistent claims; cannot authenticate measurements or logs. */
|
|
37
|
+
export function validateEngineeringRecord(record) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
const require = (condition, message) => { if (!condition) errors.push(`engineering/${record?.kind ?? 'record'}: ${message}`); };
|
|
40
|
+
if (!object(record)) return ['engineering/record: expected object'];
|
|
41
|
+
require(record.version === 1, 'version must be 1');
|
|
42
|
+
require(ENGINEERING_KINDS.has(record.kind), 'unknown kind');
|
|
43
|
+
require(text(record.checkpoint), 'checkpoint is required');
|
|
44
|
+
require(strings(record.evidence), 'nonempty evidence locators are required');
|
|
45
|
+
if (record.kind === 'performance') {
|
|
46
|
+
require(['baseline', 'improved', 'no-gain', 'inconclusive'].includes(record.outcome), 'invalid outcome');
|
|
47
|
+
require(text(record.operation), 'user operation is required');
|
|
48
|
+
require(text(record.uncertainty), 'measurement uncertainty and exclusions must be explained');
|
|
49
|
+
if (record.outcome === 'inconclusive' && record.baseline == null && record.candidate == null) return errors;
|
|
50
|
+
const checkMeasurement = (m, label) => {
|
|
51
|
+
if (!object(m)) { require(false, `${label} measurement is required`); return false; }
|
|
52
|
+
for (const field of ['checkpoint', 'entrypoint', 'environment', 'clock', 'workload']) require(text(m[field]), `${label}.${field} is required`);
|
|
53
|
+
require(digest(m.workload_digest), `${label}.workload_digest must be SHA-256`);
|
|
54
|
+
require(['ms', 'bytes', 'ops/s'].includes(m.unit), `${label}.unit must be ms, bytes or ops/s`);
|
|
55
|
+
require(['lower', 'higher'].includes(m.direction), `${label}.direction is required`);
|
|
56
|
+
require(['cold', 'warm', 'not-applicable'].includes(m.cache), `${label}.cache is required`);
|
|
57
|
+
require(Array.isArray(m.excluded_costs) && m.excluded_costs.every(text), `${label}.excluded_costs must be a string array`);
|
|
58
|
+
require(samples(m.samples), `${label}.samples must contain finite nonnegative observed values`);
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
checkMeasurement(record.baseline, 'baseline');
|
|
62
|
+
if (record.candidate != null || record.outcome === 'improved' || record.outcome === 'no-gain') {
|
|
63
|
+
checkMeasurement(record.candidate, 'candidate');
|
|
64
|
+
if (object(record.baseline) && object(record.candidate)) {
|
|
65
|
+
for (const field of ['entrypoint', 'environment', 'clock', 'workload_digest', 'unit', 'direction', 'cache', 'excluded_costs']) {
|
|
66
|
+
require(JSON.stringify(record.baseline[field]) === JSON.stringify(record.candidate[field]), `comparison changes ${field}`);
|
|
67
|
+
}
|
|
68
|
+
if (record.outcome === 'improved') {
|
|
69
|
+
require(record.behavior_verification === 'passed', 'improvement requires behavior verification');
|
|
70
|
+
if (samples(record.baseline.samples) && samples(record.candidate.samples)) {
|
|
71
|
+
const a = mean(record.baseline.samples), b = mean(record.candidate.samples);
|
|
72
|
+
require(record.baseline.direction === 'lower' ? b < a : b > a, 'improvement contradicts observed arithmetic means');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
} else if (record.kind === 'documentation') {
|
|
78
|
+
require(['verified', 'partial', 'blocked'].includes(record.outcome), 'invalid outcome');
|
|
79
|
+
require(strings(record.owner_paths), 'owning sources are required');
|
|
80
|
+
require(typeof record.visible_behavior_changed === 'boolean', 'visible_behavior_changed must be boolean');
|
|
81
|
+
require(['passed', 'failed', 'not-run', 'not-applicable'].includes(record.behavior_verification), 'invalid behavior verification');
|
|
82
|
+
require(Array.isArray(record.claims) && record.claims.length > 0, 'claims are required');
|
|
83
|
+
if (Array.isArray(record.claims)) for (const claim of record.claims) {
|
|
84
|
+
if (!object(claim)) { require(false, 'claim must be an object'); continue; }
|
|
85
|
+
require(text(claim.subject) && strings(claim.evidence), 'claim needs subject and evidence');
|
|
86
|
+
require(['command', 'source', 'manual'].includes(claim.method), 'invalid claim method');
|
|
87
|
+
require(['verified', 'unverified'].includes(claim.state), 'invalid claim state');
|
|
88
|
+
if (claim.method === 'command' && claim.state === 'verified') {
|
|
89
|
+
require(text(claim.command) && claim.executed === true && claim.exit_code === 0 && strings(claim.output), 'verified command needs execution, zero exit and observed output');
|
|
90
|
+
}
|
|
91
|
+
if (claim.state === 'unverified') require(text(claim.reason), 'unverified claim needs reason');
|
|
92
|
+
if (record.outcome === 'verified') require(claim.state === 'verified', 'overall verified has an unverified claim');
|
|
93
|
+
}
|
|
94
|
+
if (record.visible_behavior_changed && record.outcome === 'verified') require(record.behavior_verification === 'passed', 'visible change requires behavior evidence');
|
|
95
|
+
} else if (record.kind === 'incident') {
|
|
96
|
+
require(record.feedback_loop_ready === false && record.root_cause_confirmed === false, 'event triage must not assert feedback readiness or confirmed cause');
|
|
97
|
+
require(strings(record.facts) && strings(record.next_checks), 'observed facts and next safe checks are required');
|
|
98
|
+
require(Array.isArray(record.unknowns) && record.unknowns.length > 0 && record.unknowns.every(text), 'missing evidence must stay visible');
|
|
99
|
+
}
|
|
100
|
+
return errors;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read existing owners only. Explicit --engineering requires a matching record. */
|
|
104
|
+
export function validateEngineeringArtifacts(change, requiredKind = null) {
|
|
105
|
+
const errors = [], found = new Set();
|
|
106
|
+
const root = realpathSync(change);
|
|
107
|
+
const safe = (file, directory = false) => {
|
|
108
|
+
if (!existsSync(file)) return false;
|
|
109
|
+
const stat = lstatSync(file);
|
|
110
|
+
if (stat.isSymbolicLink()) { errors.push('engineering/path: symlinked evidence owners are not read'); return false; }
|
|
111
|
+
const rel = relative(root, realpathSync(file));
|
|
112
|
+
if (isAbsolute(rel) || rel === '..' || rel.startsWith('../') || rel.startsWith('..\\')) { errors.push('engineering/path: evidence escapes change'); return false; }
|
|
113
|
+
return directory ? stat.isDirectory() : stat.isFile();
|
|
114
|
+
};
|
|
115
|
+
const files = [[join(change, 'architecture-review.md'), 'performance'], [join(change, 'diagnosis.md'), 'incident']];
|
|
116
|
+
const evidenceRoot = join(change, 'evidence');
|
|
117
|
+
if (safe(evidenceRoot, true)) for (const name of readdirSync(evidenceRoot).filter(n => n.endsWith('.md')).sort()) {
|
|
118
|
+
files.push([join(evidenceRoot, name), 'documentation']);
|
|
119
|
+
}
|
|
120
|
+
for (const [file, ownerKind] of files) {
|
|
121
|
+
if (!safe(file)) continue;
|
|
122
|
+
const parsed = engineeringRecords(readFileSync(file, 'utf8'));
|
|
123
|
+
errors.push(...parsed.errors.map(e => `${relative(change, file)}: ${e}`));
|
|
124
|
+
for (const record of parsed.records) {
|
|
125
|
+
if (record?.kind !== ownerKind) errors.push(`${relative(change, file)}: engineering/owner: expected ${ownerKind}`);
|
|
126
|
+
else found.add(record.kind);
|
|
127
|
+
errors.push(...validateEngineeringRecord(record).map(e => `${relative(change, file)}: ${e}`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (requiredKind && !found.has(requiredKind)) errors.push(`engineering/${requiredKind}: missing evidence in existing owner artifact`);
|
|
131
|
+
return errors;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Keep optional branches reachable from their owning entry without a new registry. */
|
|
135
|
+
export function validateEngineeringPackage(root) {
|
|
136
|
+
const errors = [];
|
|
137
|
+
for (const [entry, target] of [
|
|
138
|
+
['R-review-architecture/R-review-architecture.md', 'R-review-architecture/references/performance-review.md'],
|
|
139
|
+
['I-implement/I-implement.md', 'I-implement/references/documentation-procedure.md'],
|
|
140
|
+
['D-diagnose-bugs/D-diagnose-bugs.md', 'D-diagnose-bugs/references/incident-evidence.md'],
|
|
141
|
+
]) {
|
|
142
|
+
if (!existsSync(join(root, target))) errors.push(`engineering/route: missing ${target}`);
|
|
143
|
+
const source = join(root, entry);
|
|
144
|
+
if (!existsSync(source) || !readFileSync(source, 'utf8').includes(`<Path>{roots.workflows}/specdev/${target}</Path>`)) errors.push(`engineering/route: ${entry} does not expose ${target}`);
|
|
145
|
+
}
|
|
146
|
+
for (const rule of ['error-handling', 'test-reliability', 'agent-experience', 'documentation-standard', 'engineering-evidence']) {
|
|
147
|
+
if (!existsSync(join(root, 'common', 'rules', `${rule}.md`))) errors.push(`engineering/rule: missing ${rule}`);
|
|
148
|
+
}
|
|
149
|
+
return errors;
|
|
150
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Read-only Git review selection. Resolves refs once; never fetches or writes state. */
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const modes = new Set(['baseline', 'range', 'commit', 'pr']);
|
|
6
|
+
function git(repo, args, encoding = 'utf8') {
|
|
7
|
+
return execFileSync('git', ['-C', repo, ...args], {
|
|
8
|
+
encoding, maxBuffer: 32 * 1024 * 1024, timeout: 30000,
|
|
9
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
10
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' },
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function commit(repo, ref, field) {
|
|
14
|
+
if (typeof ref !== 'string' || !ref || ref.startsWith('-') || ref.includes('\0')) {
|
|
15
|
+
throw new Error(`review-scope: ${field} must be an explicit Git ref`);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
return git(repo, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
|
|
19
|
+
} catch {
|
|
20
|
+
throw new Error(`review-scope: ${field} cannot resolve to a local commit; read/fetch the authorized object first`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Resolve the exact comparison and return bounded metadata, not the entire diff. */
|
|
25
|
+
export function resolveReviewScope(options) {
|
|
26
|
+
const { repo, mode } = options;
|
|
27
|
+
if (typeof repo !== 'string' || !repo || !modes.has(mode)) {
|
|
28
|
+
throw new Error('review-scope: --repo and --mode baseline|range|commit|pr are required');
|
|
29
|
+
}
|
|
30
|
+
git(repo, ['rev-parse', '--git-dir']);
|
|
31
|
+
let base, head;
|
|
32
|
+
let selectedParent = null;
|
|
33
|
+
if (mode === 'commit') {
|
|
34
|
+
if (options.base !== undefined || options.head !== undefined) throw new Error('review-scope: commit mode does not accept base/head');
|
|
35
|
+
head = commit(repo, options.commit, 'commit');
|
|
36
|
+
const parents = git(repo, ['rev-list', '--parents', '-n', '1', head]).trim().split(/\s+/).slice(1);
|
|
37
|
+
if (!parents.length) throw new Error('review-scope: root commit has no committed baseline; do not fabricate a CR fixed_point');
|
|
38
|
+
if (parents.length > 1 && options.parent === undefined) throw new Error('review-scope: merge commit requires explicit --parent');
|
|
39
|
+
selectedParent = options.parent === undefined ? 1 : Number(options.parent);
|
|
40
|
+
if (!Number.isSafeInteger(selectedParent) || selectedParent < 1 || selectedParent > parents.length) throw new Error('review-scope: invalid parent number');
|
|
41
|
+
base = parents[selectedParent - 1];
|
|
42
|
+
} else {
|
|
43
|
+
if (options.commit !== undefined || options.parent !== undefined) throw new Error('review-scope: commit/parent require commit mode');
|
|
44
|
+
base = commit(repo, options.base, 'base');
|
|
45
|
+
head = commit(repo, options.head, 'head');
|
|
46
|
+
if (mode === 'pr' && (![options.base, options.head].every(value => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(value)))) {
|
|
47
|
+
throw new Error('review-scope: PR mode requires exact remotely observed base/head SHAs, not moving names');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
let comparisonBase = base;
|
|
51
|
+
if (mode === 'baseline' || mode === 'pr') {
|
|
52
|
+
const bases = git(repo, ['merge-base', '--all', base, head]).trim().split(/\s+/).filter(Boolean);
|
|
53
|
+
if (bases.length !== 1) throw new Error('review-scope: comparison requires one unambiguous merge base');
|
|
54
|
+
comparisonBase = bases[0];
|
|
55
|
+
}
|
|
56
|
+
const diffArgs = ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--binary', '--no-renames', comparisonBase, head, '--'];
|
|
57
|
+
const diff = git(repo, diffArgs, null);
|
|
58
|
+
const names = git(repo, ['diff', '--no-color', '--no-ext-diff', '--no-textconv', '--no-renames', '--name-only', '-z', comparisonBase, head, '--'])
|
|
59
|
+
.split('\0').filter(Boolean);
|
|
60
|
+
return {
|
|
61
|
+
version: 1, mode, outcome: names.length ? 'changes' : 'no-diff',
|
|
62
|
+
base_sha: base, head_sha: head, fixed_point: comparisonBase,
|
|
63
|
+
parent: selectedParent, diff_sha256: createHash('sha256').update(diff).digest('hex'),
|
|
64
|
+
diff_bytes: diff.length, changed_paths: names, diff_command: ['git', ...diffArgs],
|
|
65
|
+
log_command: ['git', 'log', `${comparisonBase}..${head}`, '--oneline', '--'],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Strict argv admission; inputs are passed to Git as argv, never as shell text. */
|
|
70
|
+
export function reviewScopeCLI(argv) {
|
|
71
|
+
const options = {};
|
|
72
|
+
const allowed = new Set(['repo', 'mode', 'base', 'head', 'commit', 'parent']);
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const key = argv[i].startsWith('--') ? argv[i].slice(2) : '';
|
|
75
|
+
if (!allowed.has(key) || Object.hasOwn(options, key)) throw new Error(`review-scope: invalid or duplicate option ${argv[i]}`);
|
|
76
|
+
const value = argv[++i];
|
|
77
|
+
if (!value || value.startsWith('--')) throw new Error(`review-scope: missing ${key}`);
|
|
78
|
+
options[key] = value;
|
|
79
|
+
}
|
|
80
|
+
console.log(JSON.stringify(resolveReviewScope(options), null, 2));
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Read-only admission and real-entry regressions; no network or user state. */
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, symlinkSync, existsSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
9
|
+
import { engineeringRecords, validateEngineeringRecord, validateEngineeringArtifacts, validateEngineeringPackage } from '../engineering-evidence.mjs';
|
|
10
|
+
import { resolveReviewScope } from '../review-scope.mjs';
|
|
11
|
+
|
|
12
|
+
const workflow = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
|
13
|
+
const cli = join(workflow, 'common/tools/validate-specdev.mjs');
|
|
14
|
+
function temporary(t) {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), 'specdev-engineering-'));
|
|
16
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
17
|
+
return root;
|
|
18
|
+
}
|
|
19
|
+
const fence = record => '\n```json specdev-engineering-evidence\n' + JSON.stringify(record, null, 2) + '\n```\n';
|
|
20
|
+
const measurement = () => ({ checkpoint: 'baseline-revision', entrypoint: 'node public-cli.mjs', environment: 'isolated test host', clock: 'process start to ready', workload: 'deterministic fixture', workload_digest: 'a'.repeat(64), unit: 'ms', direction: 'lower', cache: 'cold', excluded_costs: ['external model network'], samples: [20, 22, 21] });
|
|
21
|
+
const performance = () => ({ version: 1, kind: 'performance', checkpoint: 'baseline-revision', evidence: ['observed measurement output'], operation: 'open project', outcome: 'baseline', uncertainty: 'Synthetic workload; no live model latency measured.', baseline: measurement() });
|
|
22
|
+
const documentation = () => ({ version: 1, kind: 'documentation', checkpoint: 'documentation-revision', evidence: ['observed command output'], owner_paths: ['README owner'], outcome: 'verified', visible_behavior_changed: false, behavior_verification: 'not-applicable', claims: [{ subject: 'example runs', method: 'command', state: 'verified', evidence: ['example stdout'], command: 'node example.mjs', executed: true, exit_code: 0, output: ['actual expected output'] }] });
|
|
23
|
+
const incident = () => ({ version: 1, kind: 'incident', checkpoint: 'incident-revision', evidence: ['sanitized incident log'], facts: ['request timed out'], unknowns: ['remote effect unknown'], next_checks: ['read remote marker'], feedback_loop_ready: false, root_cause_confirmed: false });
|
|
24
|
+
function change(t) {
|
|
25
|
+
const root = join(temporary(t), '2026-10-11-engineering');
|
|
26
|
+
mkdirSync(root);
|
|
27
|
+
const status = JSON.parse(readFileSync(join(workflow, 'I-init-setup/change-status-template.json'), 'utf8'));
|
|
28
|
+
status.change = '2026-10-11-engineering';
|
|
29
|
+
status.created_at = status.updated_at = status.leadership.assigned_at = '2026-10-11T00:00:00Z';
|
|
30
|
+
status.leadership.current = 'test-owner';
|
|
31
|
+
writeFileSync(join(root, '.status.json'), JSON.stringify(status, null, 2) + '\n');
|
|
32
|
+
return root;
|
|
33
|
+
}
|
|
34
|
+
function run(args) {
|
|
35
|
+
const result = spawnSync(process.execPath, [cli, ...args], { encoding: 'utf8', timeout: 30000 });
|
|
36
|
+
assert.equal(result.error, undefined);
|
|
37
|
+
return { ...result, output: result.stdout + result.stderr };
|
|
38
|
+
}
|
|
39
|
+
function git(repo, ...args) {
|
|
40
|
+
return execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1' } }).trim();
|
|
41
|
+
}
|
|
42
|
+
function repository(t) {
|
|
43
|
+
const root = temporary(t);
|
|
44
|
+
git(root, 'init', '-b', 'main');
|
|
45
|
+
git(root, 'config', 'user.name', 'SpecDev fixture');
|
|
46
|
+
git(root, 'config', 'user.email', 'fixture@example.invalid');
|
|
47
|
+
git(root, 'config', 'commit.gpgsign', 'false');
|
|
48
|
+
function commit(name) {
|
|
49
|
+
writeFileSync(join(root, name + '.txt'), name + '\n');
|
|
50
|
+
git(root, 'add', '--', name + '.txt');
|
|
51
|
+
git(root, 'commit', '-m', name);
|
|
52
|
+
return git(root, 'rev-parse', 'HEAD');
|
|
53
|
+
}
|
|
54
|
+
return { root, a: commit('a'), b: commit('b'), c: commit('c'), commit };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const factory of [performance, documentation, incident]) {
|
|
58
|
+
test(`accepts a consistent ${factory().kind} observation`, () => assert.deepEqual(validateEngineeringRecord(factory()), []));
|
|
59
|
+
}
|
|
60
|
+
for (const [name, mutate] of [
|
|
61
|
+
['unknown version', r => r.version = 2],
|
|
62
|
+
['empty evidence', r => r.evidence = []],
|
|
63
|
+
['empty samples', r => r.baseline.samples = []],
|
|
64
|
+
['string samples', r => r.baseline.samples = ['20']],
|
|
65
|
+
['negative samples', r => r.baseline.samples = [-1]],
|
|
66
|
+
['infinite samples', r => r.baseline.samples = [Infinity]],
|
|
67
|
+
['missing endpoint', r => r.baseline.entrypoint = ''],
|
|
68
|
+
['unknown cache', r => r.baseline.cache = 'automatic'],
|
|
69
|
+
['placeholder digest', r => r.baseline.workload_digest = 'replace-me'],
|
|
70
|
+
]) test(`performance rejects ${name}`, () => {
|
|
71
|
+
const record = performance(); mutate(record);
|
|
72
|
+
assert.ok(validateEngineeringRecord(record).length);
|
|
73
|
+
});
|
|
74
|
+
test('inconclusive performance can honestly record missing measurements', () => {
|
|
75
|
+
const record = performance(); record.outcome = 'inconclusive'; delete record.baseline;
|
|
76
|
+
assert.deepEqual(validateEngineeringRecord(record), []);
|
|
77
|
+
});
|
|
78
|
+
test('an improvement requires comparable samples and behavior verification', () => {
|
|
79
|
+
const record = performance(); record.outcome = 'improved'; record.behavior_verification = 'passed';
|
|
80
|
+
record.candidate = { ...measurement(), checkpoint: 'candidate-revision', samples: [10, 12, 11] };
|
|
81
|
+
assert.deepEqual(validateEngineeringRecord(record), []);
|
|
82
|
+
record.candidate.samples = [30, 32, 31];
|
|
83
|
+
assert.match(validateEngineeringRecord(record).join('\n'), /arithmetic means/);
|
|
84
|
+
record.candidate.samples = [10, 12, 11]; record.behavior_verification = 'not-run';
|
|
85
|
+
assert.match(validateEngineeringRecord(record).join('\n'), /behavior verification/);
|
|
86
|
+
});
|
|
87
|
+
for (const field of ['entrypoint', 'environment', 'clock', 'workload_digest', 'unit', 'direction', 'cache', 'excluded_costs']) {
|
|
88
|
+
test(`performance comparison cannot silently change ${field}`, () => {
|
|
89
|
+
const r = performance(); r.outcome = 'improved'; r.behavior_verification = 'passed';
|
|
90
|
+
r.candidate = { ...measurement(), checkpoint: 'candidate', samples: [1], [field]: field === 'excluded_costs' ? [] : 'different' };
|
|
91
|
+
assert.match(validateEngineeringRecord(r).join('\n'), new RegExp(`comparison changes ${field}`));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
for (const [name, mutate] of [
|
|
95
|
+
['unexecuted command', r => r.claims[0].executed = false],
|
|
96
|
+
['nonzero exit', r => r.claims[0].exit_code = 1],
|
|
97
|
+
['missing output', r => r.claims[0].output = []],
|
|
98
|
+
['unverified aggregate', r => { r.claims[0].state = 'unverified'; r.claims[0].reason = 'no access'; }],
|
|
99
|
+
['visible behavior without evidence', r => r.visible_behavior_changed = true],
|
|
100
|
+
]) test(`documentation rejects ${name}`, () => {
|
|
101
|
+
const r = documentation(); mutate(r); assert.ok(validateEngineeringRecord(r).length);
|
|
102
|
+
});
|
|
103
|
+
test('documentation permits honest partial verification', () => {
|
|
104
|
+
const r = documentation(); r.outcome = 'partial'; r.claims[0].state = 'unverified'; r.claims[0].reason = 'environment unavailable';
|
|
105
|
+
assert.deepEqual(validateEngineeringRecord(r), []);
|
|
106
|
+
});
|
|
107
|
+
test('incident observation cannot assert readiness or confirmed cause', () => {
|
|
108
|
+
for (const field of ['feedback_loop_ready', 'root_cause_confirmed']) {
|
|
109
|
+
const r = incident(); r[field] = true; assert.match(validateEngineeringRecord(r).join('\n'), /must not assert/);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
test('fence parser ignores nested examples and checks torn or malformed selected records', () => {
|
|
113
|
+
assert.deepEqual(engineeringRecords('````markdown\n' + fence(performance()) + '````\n'), { records: [], errors: [] });
|
|
114
|
+
assert.equal(engineeringRecords(fence(performance())).records.length, 1);
|
|
115
|
+
assert.ok(engineeringRecords('```json specdev-engineering-evidence\n{\n```\n').errors.length);
|
|
116
|
+
assert.ok(engineeringRecords('```json specdev-engineering-evidence\n{}').errors.length);
|
|
117
|
+
assert.ok(validateEngineeringRecord(null).length);
|
|
118
|
+
assert.ok(validateEngineeringRecord([]).length);
|
|
119
|
+
});
|
|
120
|
+
test('legacy unmarked reports remain unchanged and require no new record', t => {
|
|
121
|
+
const root = change(t), file = join(root, 'architecture-review.md');
|
|
122
|
+
writeFileSync(file, '# Historical report\nUnstructured, retained bytes.\n');
|
|
123
|
+
const before = readFileSync(file);
|
|
124
|
+
assert.deepEqual(validateEngineeringArtifacts(root), []);
|
|
125
|
+
assert.deepEqual(readFileSync(file), before);
|
|
126
|
+
});
|
|
127
|
+
test('record ownership is checked without creating absent artifacts', t => {
|
|
128
|
+
const root = change(t); const before = readdirSync(root);
|
|
129
|
+
assert.match(validateEngineeringArtifacts(root, 'performance').join('\n'), /missing evidence/);
|
|
130
|
+
assert.deepEqual(readdirSync(root), before);
|
|
131
|
+
writeFileSync(join(root, 'architecture-review.md'), fence(documentation()));
|
|
132
|
+
assert.match(validateEngineeringArtifacts(root).join('\n'), /engineering\/owner/);
|
|
133
|
+
});
|
|
134
|
+
test('symlinked observation owners cannot read outside a change', t => {
|
|
135
|
+
const root = change(t), outside = join(dirname(root), 'outside.md');
|
|
136
|
+
writeFileSync(outside, fence(performance()));
|
|
137
|
+
try { symlinkSync(outside, join(root, 'architecture-review.md')); }
|
|
138
|
+
catch (e) { if (e.code === 'EPERM') return t.skip('host does not permit fixture symlinks'); throw e; }
|
|
139
|
+
assert.match(validateEngineeringArtifacts(root).join('\n'), /symlinked evidence owners/);
|
|
140
|
+
});
|
|
141
|
+
test('all specialized branches are discoverable from their unchanged owner entries', () => {
|
|
142
|
+
assert.deepEqual(validateEngineeringPackage(workflow), []);
|
|
143
|
+
});
|
|
144
|
+
test('real validator accepts measurements and rejects the controlled regression', t => {
|
|
145
|
+
const root = change(t), status = readFileSync(join(root, '.status.json'));
|
|
146
|
+
const file = join(root, 'architecture-review.md'), record = performance();
|
|
147
|
+
writeFileSync(file, '# Measurement\n' + fence(record));
|
|
148
|
+
const good = run(['--engineering', 'performance', root]); assert.equal(good.status, 0, good.output);
|
|
149
|
+
record.baseline.samples = []; writeFileSync(file, '# Measurement\n' + fence(record));
|
|
150
|
+
const bad = run(['--engineering', 'performance', root]); assert.equal(bad.status, 1, bad.output); assert.match(bad.output, /samples/);
|
|
151
|
+
assert.deepEqual(readFileSync(join(root, '.status.json')), status);
|
|
152
|
+
});
|
|
153
|
+
test('ordinary real validation also rejects malformed marked observations', t => {
|
|
154
|
+
const root = change(t); writeFileSync(join(root, 'architecture-review.md'), '```json specdev-engineering-evidence\n{\n```');
|
|
155
|
+
const result = run([root]); assert.equal(result.status, 1); assert.match(result.output, /engineering\/fence/);
|
|
156
|
+
});
|
|
157
|
+
test('specialist CLI admission requires its existing artifact and exclusive flags', t => {
|
|
158
|
+
const root = change(t);
|
|
159
|
+
for (const args of [ ['--engineering', 'performance', root], ['--engineering', 'wrong', root], ['--engineering', 'performance', '--self-check'], ['--engineering', 'performance', '--engineering', 'performance', root] ]) {
|
|
160
|
+
const r = run(args); assert.notEqual(r.status, 0, JSON.stringify(args));
|
|
161
|
+
}
|
|
162
|
+
assert.deepEqual(readdirSync(root), ['.status.json']);
|
|
163
|
+
});
|
|
164
|
+
test('real documentation validation does not accept an unexecuted command', t => {
|
|
165
|
+
const root = change(t); mkdirSync(join(root, 'evidence'));
|
|
166
|
+
const file = join(root, 'evidence/direct-spec.md'), record = documentation();
|
|
167
|
+
writeFileSync(file, '# Document checks\n' + fence(record));
|
|
168
|
+
const good = run(['--engineering', 'documentation', root]); assert.equal(good.status, 0, good.output);
|
|
169
|
+
record.claims[0].executed = false; writeFileSync(file, fence(record));
|
|
170
|
+
const bad = run(['--engineering', 'documentation', root]); assert.equal(bad.status, 1); assert.match(bad.output, /verified command/);
|
|
171
|
+
});
|
|
172
|
+
test('incident stage cannot advance a diagnosis without its actual red feedback contract', t => {
|
|
173
|
+
const root = change(t), file = join(root, 'diagnosis.md');
|
|
174
|
+
let body = readFileSync(join(workflow, 'D-diagnose-bugs/diagnosis-template.md'), 'utf8')
|
|
175
|
+
.replace('change: <YYYY-MM-DD-topic>', 'change: 2026-10-11-engineering')
|
|
176
|
+
.replace('status: reproducing', 'status: blocked').replace('cleanup_status: pending', 'cleanup_status: clean')
|
|
177
|
+
.replace('updated_at: <ISO-8601>', 'updated_at: 2026-10-11T00:00:00Z')
|
|
178
|
+
.replace(/<Path>[^\n]+?<\/Path>/g, 'not-applicable') + fence(incident());
|
|
179
|
+
writeFileSync(file, body);
|
|
180
|
+
const good = run(['--stage', 'diagnosis', '--engineering', 'incident', root]); assert.equal(good.status, 0, good.output);
|
|
181
|
+
body = body.replace('status: blocked', 'status: root-cause-confirmed'); writeFileSync(file, body);
|
|
182
|
+
const bad = run(['--stage', 'diagnosis', root]); assert.equal(bad.status, 1); assert.match(bad.output, /requires ready feedback loop/);
|
|
183
|
+
});
|
|
184
|
+
test('single commit selection excludes later HEAD work', t => {
|
|
185
|
+
const r = repository(t), scope = resolveReviewScope({ repo: r.root, mode: 'commit', commit: r.b });
|
|
186
|
+
assert.equal(scope.head_sha, r.b); assert.equal(scope.fixed_point, r.a); assert.deepEqual(scope.changed_paths, ['b.txt']);
|
|
187
|
+
});
|
|
188
|
+
test('no-diff is a read-only outcome, not an approval or failed reference', t => {
|
|
189
|
+
const r = repository(t), before = git(r.root, 'status', '--porcelain');
|
|
190
|
+
const result = run(['--review-scope', '--repo', r.root, '--mode', 'range', '--base', r.c, '--head', r.c]);
|
|
191
|
+
assert.equal(result.status, 0, result.output);
|
|
192
|
+
const scope = JSON.parse(result.stdout); assert.equal(scope.outcome, 'no-diff'); assert.equal(scope.diff_bytes, 0); assert.deepEqual(scope.changed_paths, []);
|
|
193
|
+
assert.equal(Object.hasOwn(scope, 'approved'), false); assert.equal(git(r.root, 'status', '--porcelain'), before);
|
|
194
|
+
});
|
|
195
|
+
test('new HEAD invalidates a frozen review digest', t => {
|
|
196
|
+
const r = repository(t), first = resolveReviewScope({ repo: r.root, mode: 'baseline', base: r.a, head: 'HEAD' });
|
|
197
|
+
r.commit('d'); const next = resolveReviewScope({ repo: r.root, mode: 'baseline', base: r.a, head: 'HEAD' });
|
|
198
|
+
assert.notEqual(first.head_sha, next.head_sha); assert.notEqual(first.diff_sha256, next.diff_sha256);
|
|
199
|
+
});
|
|
200
|
+
test('PR selection requires both exact observed remote SHAs', t => {
|
|
201
|
+
const r = repository(t);
|
|
202
|
+
assert.equal(resolveReviewScope({ repo: r.root, mode: 'pr', base: r.a, head: r.c }).head_sha, r.c);
|
|
203
|
+
assert.throws(() => resolveReviewScope({ repo: r.root, mode: 'pr', base: r.a, head: 'HEAD' }), /exact remotely observed/);
|
|
204
|
+
});
|
|
205
|
+
test('invalid refs and ambiguous comparison options are refused', t => {
|
|
206
|
+
const r = repository(t);
|
|
207
|
+
for (const options of [
|
|
208
|
+
{ mode: 'range', base: 'not-a-ref', head: r.c }, { mode: 'range', base: '--all', head: r.c },
|
|
209
|
+
{ mode: 'commit', commit: r.a }, { mode: 'commit', commit: r.b, head: r.c },
|
|
210
|
+
{ mode: 'commit', commit: r.b, parent: 3 }, { mode: 'range', base: r.a, head: r.c, parent: 1 },
|
|
211
|
+
]) assert.throws(() => resolveReviewScope({ repo: r.root, ...options }));
|
|
212
|
+
assert.notEqual(run(['--review-scope', '--repo', r.root, '--repo', r.root]).status, 0);
|
|
213
|
+
});
|
|
214
|
+
test('merge commit requires an explicit parent', t => {
|
|
215
|
+
const r = repository(t); git(r.root, 'checkout', '-b', 'side', r.a); const side = r.commit('side');
|
|
216
|
+
git(r.root, 'checkout', 'main'); git(r.root, 'merge', '--no-ff', 'side', '-m', 'merge'); const merge = git(r.root, 'rev-parse', 'HEAD');
|
|
217
|
+
assert.throws(() => resolveReviewScope({ repo: r.root, mode: 'commit', commit: merge }), /explicit --parent/);
|
|
218
|
+
assert.equal(resolveReviewScope({ repo: r.root, mode: 'commit', commit: merge, parent: 2 }).fixed_point, side);
|
|
219
|
+
assert.equal(resolveReviewScope({ repo: r.root, mode: 'commit', commit: merge, parent: 1 }).fixed_point, r.c);
|
|
220
|
+
});
|
|
221
|
+
test('range comparison and merge-base comparison have distinct semantics', t => {
|
|
222
|
+
const r = repository(t); git(r.root, 'checkout', '-b', 'side', r.a); const side = r.commit('side');
|
|
223
|
+
const baseline = resolveReviewScope({ repo: r.root, mode: 'baseline', base: r.c, head: side });
|
|
224
|
+
const range = resolveReviewScope({ repo: r.root, mode: 'range', base: r.c, head: side });
|
|
225
|
+
assert.deepEqual(baseline.changed_paths, ['side.txt']); assert.deepEqual(range.changed_paths, ['b.txt', 'c.txt', 'side.txt']);
|
|
226
|
+
});
|
|
227
|
+
test('fifteen Work IDs and persisted schema versions remain unchanged', () => {
|
|
228
|
+
const entries = readdirSync(workflow).filter(name => /^[A-Z]-/.test(name)); assert.equal(entries.length, 15);
|
|
229
|
+
const ids = entries.map(name => /^id: (.+)$/m.exec(readFileSync(join(workflow, name, `${name}.md`), 'utf8'))?.[1]);
|
|
230
|
+
assert.deepEqual(ids.sort(), ['archive-and-consolidate','code-review','diagnose-bugs','goal-plan','grill-with-docs','implement','init-setup','learn-change','prototype','retro','review-architecture','spec','tickets','triage','wayfinder'].map(id => 'specdev/' + id).sort());
|
|
231
|
+
for (const [file, version] of [['config', 6], ['status', 5], ['change-status', 6], ['goal-plan', 6]]) {
|
|
232
|
+
const schema = JSON.parse(readFileSync(join(workflow, 'common/schemas', `${file}.schema.json`), 'utf8'));
|
|
233
|
+
assert.equal(schema.properties.schema_version.const, version);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('review scope ignores configured external diff and forced color', t => {
|
|
238
|
+
const r = repository(t), sentinel = join(r.root, 'unexpected-diff-effect');
|
|
239
|
+
const plain = resolveReviewScope({ repo: r.root, mode: 'range', base: r.a, head: r.c });
|
|
240
|
+
git(r.root, 'config', 'color.ui', 'always');
|
|
241
|
+
git(r.root, 'config', 'diff.external', `node -e "require('fs').writeFileSync('${sentinel.replaceAll('\\', '/')}', 'bad')"`);
|
|
242
|
+
const scope = resolveReviewScope({ repo: r.root, mode: 'range', base: r.a, head: r.c });
|
|
243
|
+
assert.equal(scope.diff_sha256, plain.diff_sha256); assert.equal(existsSync(sentinel), false);
|
|
244
|
+
});
|
|
245
|
+
test('specialist validation errors do not create remote ledgers or reopen completed states', t => {
|
|
246
|
+
const root = change(t), statusPath = join(root, '.status.json');
|
|
247
|
+
const status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
248
|
+
status.change_status = 'completed'; status.completed_at = '2026-10-11T00:00:00Z';
|
|
249
|
+
writeFileSync(statusPath, JSON.stringify(status)); const before = readFileSync(statusPath);
|
|
250
|
+
const r = run(['--engineering', 'performance', root]); assert.notEqual(r.status, 0);
|
|
251
|
+
assert.deepEqual(readFileSync(statusPath), before); assert.deepEqual(readdirSync(root), ['.status.json']);
|
|
252
|
+
});
|
|
253
|
+
test('correcting a candidate preserves historical incident observation bytes', t => {
|
|
254
|
+
const root = change(t), original = fence(incident()), file = join(root, 'diagnosis.md');
|
|
255
|
+
writeFileSync(file, original);
|
|
256
|
+
assert.deepEqual(validateEngineeringArtifacts(root, 'incident'), []);
|
|
257
|
+
const subsequent = { ...incident(), checkpoint: 'later-source', facts: ['new read-only evidence'] };
|
|
258
|
+
writeFileSync(file, original + fence(subsequent));
|
|
259
|
+
assert.deepEqual(validateEngineeringArtifacts(root, 'incident'), []);
|
|
260
|
+
assert.ok(readFileSync(file, 'utf8').startsWith(original));
|
|
261
|
+
});
|
|
262
|
+
test('a valid-looking measurement in another owner does not satisfy requested performance', t => {
|
|
263
|
+
const root = change(t); mkdirSync(join(root, 'evidence'));
|
|
264
|
+
writeFileSync(join(root, 'evidence/direct-spec.md'), fence(performance()));
|
|
265
|
+
const r = run(['--engineering', 'performance', root]); assert.equal(r.status, 1);
|
|
266
|
+
assert.match(r.output, /engineering\/owner/); assert.match(r.output, /missing evidence/);
|
|
267
|
+
});
|
|
@@ -26,6 +26,8 @@ import {
|
|
|
26
26
|
} from "./plan-contract.mjs";
|
|
27
27
|
|
|
28
28
|
import { validateRecord, validateRecords } from "./delivery-artifacts.mjs";
|
|
29
|
+
import { reviewScopeCLI } from "./review-scope.mjs";
|
|
30
|
+
import { ENGINEERING_KINDS, validateEngineeringArtifacts, validateEngineeringPackage } from "./engineering-evidence.mjs";
|
|
29
31
|
|
|
30
32
|
const DOMAIN_SCHEMA_VERSION = 3;
|
|
31
33
|
const CONFIG_SCHEMA_VERSION = 6;
|
|
@@ -1077,7 +1079,7 @@ function capabilityChecks(root) {
|
|
|
1077
1079
|
"code-review",
|
|
1078
1080
|
[
|
|
1079
1081
|
join(root, "C-code-review", "C-code-review.md"),
|
|
1080
|
-
["
|
|
1082
|
+
["--review-scope", "baseline", "range", "commit", "pr", "no-diff", "固定点", "标准轴", "规范轴", "未合并排名", "reviews/CR-###.md"],
|
|
1081
1083
|
],
|
|
1082
1084
|
],
|
|
1083
1085
|
[
|
|
@@ -1274,6 +1276,16 @@ function selfCheck(root) {
|
|
|
1274
1276
|
errors.push(...references.errors);
|
|
1275
1277
|
warnings.push(...references.warnings);
|
|
1276
1278
|
errors.push(...capabilityChecks(root));
|
|
1279
|
+
errors.push(...validateEngineeringPackage(root));
|
|
1280
|
+
// Run real runner regressions; these tests never recursively request self-check.
|
|
1281
|
+
try {
|
|
1282
|
+
execFileSync(process.execPath, ["--test", join(root, "common", "tools", "tests", "engineering.test.mjs")], {
|
|
1283
|
+
encoding: "utf8", timeout: 120000, maxBuffer: 8 * 1024 * 1024,
|
|
1284
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1285
|
+
});
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
errors.push(`engineering regressions failed: ${error.stdout ?? ""}${error.stderr ?? error.message}`);
|
|
1288
|
+
}
|
|
1277
1289
|
|
|
1278
1290
|
const obsolete = [
|
|
1279
1291
|
"P-goal-plan/input-validation.md",
|
|
@@ -1647,6 +1659,9 @@ function validateDiagnosis(path, expectedChange, errors) {
|
|
|
1647
1659
|
if (typeof meta.feedback_loop_ready !== "boolean") {
|
|
1648
1660
|
errors.push("diagnosis.md: feedback_loop_ready must be boolean");
|
|
1649
1661
|
}
|
|
1662
|
+
if (["minimizing", "testing-hypotheses", "root-cause-confirmed"].includes(meta.status) && meta.feedback_loop_ready !== true) {
|
|
1663
|
+
errors.push("diagnosis.md: advanced diagnosis requires ready feedback loop and real red evidence");
|
|
1664
|
+
}
|
|
1650
1665
|
if (meta.feedback_loop_ready) {
|
|
1651
1666
|
if (!String(meta.red_command ?? "").trim() || !String(meta.red_evidence ?? "").trim()) {
|
|
1652
1667
|
errors.push("diagnosis.md: ready feedback loop requires red_command and red_evidence");
|
|
@@ -3285,7 +3300,7 @@ function validateParentImplementation(change, parentStatus, stage, errors, warni
|
|
|
3285
3300
|
return { map, plan, members, tasks, memberStatuses, ticketByTask };
|
|
3286
3301
|
}
|
|
3287
3302
|
|
|
3288
|
-
function validateChange(change, stage = null, repoRoot = null) {
|
|
3303
|
+
function validateChange(change, stage = null, repoRoot = null, engineeringKind = null) {
|
|
3289
3304
|
const errors = [];
|
|
3290
3305
|
const warnings = [];
|
|
3291
3306
|
if (!isDirectory(change)) {
|
|
@@ -3635,6 +3650,7 @@ function validateChange(change, stage = null, repoRoot = null) {
|
|
|
3635
3650
|
}
|
|
3636
3651
|
}
|
|
3637
3652
|
|
|
3653
|
+
errors.push(...validateEngineeringArtifacts(change, engineeringKind));
|
|
3638
3654
|
return { errors, warnings };
|
|
3639
3655
|
}
|
|
3640
3656
|
|
|
@@ -3646,11 +3662,13 @@ function printResults(errors, warnings) {
|
|
|
3646
3662
|
}
|
|
3647
3663
|
|
|
3648
3664
|
function usage() {
|
|
3649
|
-
console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--repo <project-root>] <change-directory> | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
|
|
3665
|
+
console.error("Usage: node validate-specdev.mjs [--stage <stage>] [--repo <project-root>] [--engineering <performance|documentation|incident>] <change-directory> | --review-scope --repo <project-root> --mode <baseline|range|commit|pr> [comparison options] | --self-check | --capture <capture.md> | --triage-run <TRI-NNN.md>");
|
|
3650
3666
|
return 2;
|
|
3651
3667
|
}
|
|
3652
3668
|
|
|
3653
3669
|
function main(argv) {
|
|
3670
|
+
if (argv[0] === "--review-scope") return reviewScopeCLI(argv.slice(1));
|
|
3671
|
+
let engineeringKind = null;
|
|
3654
3672
|
let selfCheckRequested = false;
|
|
3655
3673
|
let stage = null;
|
|
3656
3674
|
let repoRoot = null;
|
|
@@ -3659,7 +3677,10 @@ function main(argv) {
|
|
|
3659
3677
|
const positional = [];
|
|
3660
3678
|
for (let index = 0; index < argv.length; index += 1) {
|
|
3661
3679
|
const arg = argv[index];
|
|
3662
|
-
if (arg === "--
|
|
3680
|
+
if (arg === "--engineering") {
|
|
3681
|
+
if (engineeringKind !== null || !ENGINEERING_KINDS.has(argv[index + 1])) return usage();
|
|
3682
|
+
engineeringKind = argv[++index];
|
|
3683
|
+
} else if (arg === "--self-check") {
|
|
3663
3684
|
selfCheckRequested = true;
|
|
3664
3685
|
} else if (arg === "--stage") {
|
|
3665
3686
|
stage = argv[index + 1] ?? null;
|
|
@@ -3685,6 +3706,7 @@ function main(argv) {
|
|
|
3685
3706
|
}
|
|
3686
3707
|
}
|
|
3687
3708
|
if (stage !== null && !VALID_STAGES.has(stage)) return usage();
|
|
3709
|
+
if (engineeringKind !== null && (selfCheckRequested || capturePath || triageRunPath || positional.length !== 1)) return usage();
|
|
3688
3710
|
if (triageRunPath) {
|
|
3689
3711
|
if (selfCheckRequested || positional.length || stage !== null || capturePath || repoRoot) return usage();
|
|
3690
3712
|
return printResults(validateRecord(resolve(triageRunPath), "triage-run", parseFrontmatter).errors, []);
|
|
@@ -3703,7 +3725,7 @@ function main(argv) {
|
|
|
3703
3725
|
return printResults(errors, []);
|
|
3704
3726
|
}
|
|
3705
3727
|
if (positional.length === 1) {
|
|
3706
|
-
const result = validateChange(resolve(positional[0]), stage, repoRoot);
|
|
3728
|
+
const result = validateChange(resolve(positional[0]), stage, repoRoot, engineeringKind);
|
|
3707
3729
|
return printResults(result.errors, result.warnings);
|
|
3708
3730
|
}
|
|
3709
3731
|
return usage();
|