@namewta/speculo 2.0.1 → 2.0.3
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 +42 -29
- package/dist/src/agent-skills.d.ts +43 -17
- package/dist/src/agent-skills.js +95 -28
- package/dist/src/agent-skills.js.map +1 -1
- package/dist/src/cli.js +43 -27
- package/dist/src/cli.js.map +1 -1
- package/dist/src/index.d.ts +20 -6
- package/dist/src/index.js +36 -11
- package/dist/src/index.js.map +1 -1
- package/dist/src/install-ui.d.ts +61 -0
- package/dist/src/install-ui.js +268 -0
- package/dist/src/install-ui.js.map +1 -0
- package/dist/src/interaction.d.ts +5 -0
- package/dist/src/interaction.js +10 -0
- package/dist/src/interaction.js.map +1 -0
- package/dist/src/transaction.d.ts +2 -0
- package/dist/src/transaction.js +16 -0
- package/dist/src/transaction.js.map +1 -1
- package/dist/src/workflows.js +2 -2
- package/dist/src/workflows.js.map +1 -1
- package/package.json +2 -2
- package/template/canonical/canonical-specdev-goal-plan.md +1747 -662
- package/template/canonical/canonical-specdev-grill-with-docs.md +1065 -233
- package/template/canonical/canonical-specdev-spec.md +1066 -278
- package/template/canonical/canonical-specdev-tickets.md +926 -184
- package/template/canonical/canonical-specdev-wayfinder.md +1060 -222
- package/template/commands/handoff/COMMAND.md +10 -0
- package/template/references/coordination/coordination-contract.md +12 -10
- package/template/references/coordination/modes/cli.md +6 -6
- package/template/references/coordination/modes/herdr.md +18 -3
- package/template/references/coordination/modes/orca.md +32 -9
- package/template/references/coordination/result-and-recovery.md +7 -4
- package/template/references/coordination/session-files.md +93 -0
- package/template/references/coordination/task-brief.md +10 -8
- package/template/workflows/specdev/SKILL.md +2 -2
- package/template/workflows/specdev/common/rules/adr-format.md +5 -11
- package/template/workflows/specdev/common/rules/artifact-contract.md +2 -2
- package/template/workflows/specdev/common/rules/change-completion.md +7 -2
- package/template/workflows/specdev/common/rules/domain-runtime.md +2 -2
- package/template/workflows/specdev/common/rules/evidence-and-verification.md +2 -2
- package/template/workflows/specdev/common/rules/execution-policy.md +28 -0
- package/template/workflows/specdev/common/rules/operating-governance.md +2 -2
- package/template/workflows/specdev/common/rules/path-ownership.md +3 -3
- 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-routing.md +3 -3
- package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +2 -0
- package/template/workflows/specdev/common/skills/code-review/SKILL.md +12 -7
- 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/SKILL.md +11 -11
- package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +20 -12
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +14 -12
- package/template/workflows/specdev/common/tools/README.md +9 -1
- package/template/workflows/specdev/common/tools/change-artifacts.mjs +8 -11
- package/template/workflows/specdev/common/tools/plan-contract.mjs +13 -6
- package/template/workflows/specdev/common/tools/review-evidence.mjs +117 -0
- package/template/workflows/specdev/common/tools/review-scope.mjs +90 -5
- package/template/workflows/specdev/common/tools/tests/engineering.test.mjs +2 -2
- 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 +59 -0
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +210 -114
- package/template/workflows/specdev/manifest.json +4 -6
- package/template/workflows/specdev/specdev-c-code-review/SKILL.md +6 -6
- 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 +6 -6
- package/template/workflows/specdev/specdev-i-implement/assets/evidence-template.md +16 -10
- package/template/workflows/specdev/specdev-i-implement/references/execution-preflight.md +8 -4
- package/template/workflows/specdev/specdev-i-implement/references/implementation-procedure.md +22 -18
- package/template/workflows/specdev/specdev-i-init-setup/SKILL.md +1 -1
- package/template/workflows/specdev/specdev-l-learn/SKILL.md +35 -0
- package/template/workflows/specdev/specdev-l-learn/references/learning-change.md +23 -0
- package/template/workflows/specdev/specdev-l-learn/references/lesson-format.md +61 -0
- package/template/workflows/specdev/specdev-p-goal-plan/SKILL.md +3 -3
- package/template/workflows/specdev/specdev-p-goal-plan/assets/goal-plan-template.md +4 -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-p-goal-plan/references/orchestration-protocol.md +1 -1
- package/template/workflows/specdev/specdev-p-goal-plan/references/planning-modes.md +2 -2
- package/template/workflows/specdev/specdev-p-goal-plan/references/single-change-plan.md +2 -2
- package/template/workflows/specdev/specdev-r-retro/SKILL.md +1 -1
- package/template/workflows/specdev/specdev-s-spec/SKILL.md +3 -3
- package/template/workflows/specdev/specdev-s-spec/assets/spec-template.md +15 -65
- package/template/workflows/specdev/specdev-t-tickets/SKILL.md +1 -1
- 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 +3 -1
- package/template/workflows/specdev/specdev-t-tickets/references/planning-procedure.md +5 -19
- package/template/workflows/specdev/specdev-t-tickets/references/ticket-readiness.md +5 -5
- package/template/workflows/specdev/specdev-l-learn-change/SKILL.md +0 -33
- package/template/workflows/specdev/specdev-l-learn-change/references/lesson-format.md +0 -51
|
@@ -123,9 +123,8 @@ export function validateTicketPlan(artifact, { repoRoot = null, requirePlan = fa
|
|
|
123
123
|
if (meta.plan_contract_version !== 1) errors.push('unsupported plan_contract_version');
|
|
124
124
|
errors.push(...validateBindings(meta, repoRoot));
|
|
125
125
|
if (!strings(meta.resource_claims) || new Set(meta.resource_claims).size !== meta.resource_claims.length) errors.push('resource_claims must be a unique string array');
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
126
|
+
// Skill invocation is enforced by the structured bindings and real completion
|
|
127
|
+
// records below, not by repeating generic instructions in every Ticket body.
|
|
129
128
|
if (checkEvidence && meta.status === 'done' && meta.skill_bindings?.some(binding => binding?.required === true)) {
|
|
130
129
|
try {
|
|
131
130
|
const evidencePath = join(dirname(dirname(path)), 'evidence', `${meta.id}.md`);
|
|
@@ -262,13 +261,13 @@ export function validateInitiative(directory) {
|
|
|
262
261
|
return errors;
|
|
263
262
|
}
|
|
264
263
|
|
|
265
|
-
/** Read actual
|
|
264
|
+
/** Read actual Ticket or Direct Spec records; never creates empty execution slots. */
|
|
266
265
|
export function readChangeExecution(changeRoot) {
|
|
267
266
|
const root = join(changeRoot, 'evidence');
|
|
268
267
|
if (!existsSync(root)) return [];
|
|
269
268
|
if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory()) throw new Error('Evidence must be a real directory');
|
|
270
269
|
const records = [];
|
|
271
|
-
for (const file of readdirSync(root).filter(name => /^T-\d{2,}\.md$/.test(name)).sort()) {
|
|
270
|
+
for (const file of readdirSync(root).filter(name => /^T-\d{2,}\.md$/.test(name) || name === 'direct-spec.md').sort()) {
|
|
272
271
|
const path = join(root, file);
|
|
273
272
|
if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile()) throw new Error(`${path}: Evidence must be a regular file`);
|
|
274
273
|
const text = readFileSync(path, 'utf8').replaceAll('\r\n', '\n');
|
|
@@ -276,9 +275,17 @@ export function readChangeExecution(changeRoot) {
|
|
|
276
275
|
if (!sections.length) continue;
|
|
277
276
|
if (sections.length !== 1) throw new Error(`${path}: duplicate Workspace Execution Record`);
|
|
278
277
|
const record = readEvidenceValue(text,'## Workspace Execution Record');
|
|
279
|
-
|
|
278
|
+
const direct = file === 'direct-spec.md';
|
|
279
|
+
if (!plainObject(record) || (direct
|
|
280
|
+
? record.purpose !== 'direct-spec' || record.change !== basename(changeRoot) || 'ticket_id' in record
|
|
281
|
+
: record.ticket_id !== file.slice(0, -3) || 'purpose' in record || 'change' in record)) {
|
|
282
|
+
throw new Error(`${path}: execution record identity differs from Evidence ${direct ? 'Direct Spec/change' : 'Ticket'}`);
|
|
283
|
+
}
|
|
280
284
|
records.push(record);
|
|
281
285
|
}
|
|
286
|
+
if (records.some(record => record.purpose === 'direct-spec') && records.some(record => record.ticket_id)) {
|
|
287
|
+
throw new Error('Direct Spec and Ticket execution records cannot coexist; reconcile the original execution before changing modes');
|
|
288
|
+
}
|
|
282
289
|
return records;
|
|
283
290
|
}
|
|
284
291
|
|
|
@@ -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,94 @@
|
|
|
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
|
+
const root = realpathSync(resolve(options.repo));
|
|
47
|
+
if (realpathSync(git(root, ['rev-parse','--show-toplevel']).trim()) !== root) throw new Error('review-scope: working-tree requires the actual project Git root');
|
|
48
|
+
const base = commit(root, options.base, 'base');
|
|
49
|
+
const files = options.files?.map(portableFile);
|
|
50
|
+
if (files && (!files.length || new Set(files).size !== files.length)) throw new Error('review-scope: empty or duplicate working-tree scope');
|
|
51
|
+
const capture = () => {
|
|
52
|
+
if (git(root, ['ls-files','--unmerged','-z'])) throw new Error('review-scope: unresolved index conflicts');
|
|
53
|
+
const head = commit(root, 'HEAD', 'HEAD');
|
|
54
|
+
const index = git(root, ['ls-files','--stage','-z']);
|
|
55
|
+
const inventory = git(root, ['status','--porcelain=v1','-z','--untracked-files=all']);
|
|
56
|
+
const baseEntries = new Map(git(root, ['ls-tree','-r','-z','--full-tree',base]).split('\0').filter(Boolean).map(row => {
|
|
57
|
+
const tab = row.indexOf('\t'), [mode,type,oid] = row.slice(0,tab).split(' ');
|
|
58
|
+
return [row.slice(tab+1), {mode,type,oid}];
|
|
59
|
+
}));
|
|
60
|
+
const diffArgs = ['diff','--no-color','--no-ext-diff','--no-textconv','--binary','--no-renames',base,'--'];
|
|
61
|
+
const changed = [...new Set([
|
|
62
|
+
...git(root, ['diff','--no-ext-diff','--no-textconv','--no-renames','--name-only','-z',base,'--']).split('\0'),
|
|
63
|
+
...git(root, ['ls-files','--others','--exclude-standard','-z']).split('\0'),
|
|
64
|
+
].filter(Boolean))].sort();
|
|
65
|
+
const selected = files ? [...files].sort() : changed;
|
|
66
|
+
const snapshot = selected.map(path => {
|
|
67
|
+
portableFile(path);
|
|
68
|
+
const entry = baseEntries.get(path);
|
|
69
|
+
if (entry && entry.type !== 'blob') throw new Error('review-scope: submodules require separate review; working-tree snapshot is unverified');
|
|
70
|
+
const before = entry ? {mode:entry.mode, oid:entry.oid, sha256:digest(git(root, ['cat-file','blob',entry.oid], null))} : null;
|
|
71
|
+
const after = workingFile(root, path);
|
|
72
|
+
if (!before && !after) throw new Error(`review-scope: missing or unsupported working-tree path ${path}`);
|
|
73
|
+
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';
|
|
74
|
+
return {path, change, before, after};
|
|
75
|
+
});
|
|
76
|
+
const args = [...diffArgs, ...selected.map(path => `:(literal)${path}`)];
|
|
77
|
+
const scope = {
|
|
78
|
+
version:1, mode:'working-tree', review_mode:'working-tree',
|
|
79
|
+
outcome:snapshot.some(file => file.change !== 'unchanged') ? 'changes' : 'no-diff',
|
|
80
|
+
base_sha:base, observed_head:head, selection:files ? 'explicit-files' : 'all-changes', scope_paths:selected,
|
|
81
|
+
changed_paths:snapshot.filter(file => file.change !== 'unchanged').map(file => file.path),
|
|
82
|
+
excluded_paths:changed.filter(path => !selected.includes(path)),
|
|
83
|
+
working_snapshot:snapshot, index_sha256:digest(index), inventory_sha256:digest(inventory),
|
|
84
|
+
tracked_diff_sha256:digest(git(root,args,null)), tracked_diff_command:['git',...args],
|
|
85
|
+
};
|
|
86
|
+
return {...scope, input_sha256:digest(JSON.stringify(scope))};
|
|
87
|
+
};
|
|
88
|
+
const first = capture(), second = capture();
|
|
89
|
+
if (first.input_sha256 !== second.input_sha256) throw new Error('review-scope: working-tree changed while freezing inputs; stop writers and retry');
|
|
90
|
+
return first;
|
|
91
|
+
}
|
|
8
92
|
function contained(root, path) {
|
|
9
93
|
const rel = relative(root, path);
|
|
10
94
|
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
@@ -65,10 +149,11 @@ function commit(repo, ref, field) {
|
|
|
65
149
|
export function resolveReviewScope(options) {
|
|
66
150
|
const { repo, mode } = options;
|
|
67
151
|
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');
|
|
152
|
+
throw new Error('review-scope: --repo and --mode baseline|range|commit|pr|source-files|working-tree are required');
|
|
69
153
|
}
|
|
70
154
|
if (mode === 'source-files') return sourceFiles(options);
|
|
71
|
-
if (
|
|
155
|
+
if (mode === 'working-tree') return workingTree(options);
|
|
156
|
+
if (options.files !== undefined) throw new Error('review-scope: --file requires source-files or working-tree mode');
|
|
72
157
|
git(repo, ['rev-parse', '--git-dir']);
|
|
73
158
|
let base, head;
|
|
74
159
|
let selectedParent = null;
|
|
@@ -223,10 +223,10 @@ test('range comparison and merge-base comparison have distinct semantics', t =>
|
|
|
223
223
|
const range = resolveReviewScope({ repo: r.root, mode: 'range', base: r.c, head: side });
|
|
224
224
|
assert.deepEqual(baseline.changed_paths, ['side.txt']); assert.deepEqual(range.changed_paths, ['b.txt', 'c.txt', 'side.txt']);
|
|
225
225
|
});
|
|
226
|
-
test('fifteen Work IDs and persisted schema versions remain unchanged', () => {
|
|
226
|
+
test('fifteen Work IDs match the current registry and persisted schema versions remain unchanged', () => {
|
|
227
227
|
const entries = readdirSync(workflow).filter(name => /^specdev-[a-z]-/.test(name)); assert.equal(entries.length, 15);
|
|
228
228
|
const ids = entries.map(name => /speculo-id: (.+)$/m.exec(readFileSync(join(workflow, name, 'SKILL.md'), 'utf8'))?.[1]);
|
|
229
|
-
assert.deepEqual(ids.sort(), ['archive-and-consolidate','code-review','diagnose-bugs','goal-plan','grill-with-docs','implement','init-setup','learn
|
|
229
|
+
assert.deepEqual(ids.sort(), ['archive-and-consolidate','code-review','diagnose-bugs','goal-plan','grill-with-docs','implement','init-setup','learn','prototype','retro','review-architecture','spec','tickets','triage','wayfinder'].map(id => 'specdev/' + id).sort());
|
|
230
230
|
for (const [file, version] of [['config', 7], ['status', 5], ['change-status', 6], ['goal-plan', 7]]) {
|
|
231
231
|
const schema = JSON.parse(readFileSync(join(workflow, 'common/schemas', `${file}.schema.json`), 'utf8'));
|
|
232
232
|
assert.equal(schema.properties.schema_version.const, version);
|
|
@@ -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,59 @@
|
|
|
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
|
+
for(const path of ['one.txt','removed.txt','renamed.txt']) writeFileSync(join(root,path),'original\n');
|
|
15
|
+
writeFileSync(join(root,'.gitignore'),'ignored/\n');git('add','.');git('-c','commit.gpgsign=false','commit','-m','base');
|
|
16
|
+
const base=git('rev-parse','HEAD');
|
|
17
|
+
return {root,git,base,scope:(extra={})=>resolveReviewScope({repo:root,mode:'working-tree',base,...extra})};
|
|
18
|
+
}
|
|
19
|
+
test('working-tree freezes staged, unstaged, added, deleted, renamed and mode changes without writes',t=>{
|
|
20
|
+
const f=fixture(t);
|
|
21
|
+
writeFileSync(join(f.root,'one.txt'),'staged\n');f.git('add','one.txt');writeFileSync(join(f.root,'one.txt'),'final\n');
|
|
22
|
+
rmSync(join(f.root,'removed.txt'));renameSync(join(f.root,'renamed.txt'),join(f.root,'new-name.txt'));
|
|
23
|
+
writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,7]));chmodSync(join(f.root,'one.txt'),0o755);
|
|
24
|
+
mkdirSync(join(f.root,'ignored'));writeFileSync(join(f.root,'ignored/not-selected'),'ignored');
|
|
25
|
+
const before=readFileSync(join(f.root,'.git/index')), status=f.git('status','--porcelain');
|
|
26
|
+
const scope=f.scope();
|
|
27
|
+
assert.equal(scope.outcome,'changes');assert.equal(scope.observed_head,f.base);assert.equal(scope.head_sha,undefined);
|
|
28
|
+
assert.deepEqual(scope.changed_paths,['new-name.txt','new.bin','one.txt','removed.txt','renamed.txt']);
|
|
29
|
+
assert.equal(scope.working_snapshot.find(x=>x.path==='one.txt').after.mode,'100755');
|
|
30
|
+
assert.equal(scope.working_snapshot.find(x=>x.path==='removed.txt').after,null);
|
|
31
|
+
assert.equal(scope.working_snapshot.find(x=>x.path==='new.bin').after.bytes,3);
|
|
32
|
+
assert.deepEqual(f.scope(),scope);assert.equal(f.git('status','--porcelain'),status);assert.deepEqual(readFileSync(join(f.root,'.git/index')),before);
|
|
33
|
+
writeFileSync(join(f.root,'new.bin'),Buffer.from([0,255,8]));assert.notEqual(f.scope().input_sha256,scope.input_sha256);
|
|
34
|
+
});
|
|
35
|
+
test('explicit scopes are literal, expose exclusions and retain deleted files',t=>{
|
|
36
|
+
const f=fixture(t), path='literal [*] $(touch unsafe).txt';
|
|
37
|
+
writeFileSync(join(f.root,path),'literal');rmSync(join(f.root,'removed.txt'));writeFileSync(join(f.root,'one.txt'),'changed');
|
|
38
|
+
const scope=f.scope({files:[path,'removed.txt']});
|
|
39
|
+
assert.deepEqual(scope.changed_paths,[path,'removed.txt']);assert.deepEqual(scope.excluded_paths,['one.txt']);
|
|
40
|
+
assert.equal(scope.selection,'explicit-files');
|
|
41
|
+
for(const files of [[],['../outside'],['.git/config'],['one.txt','one.txt'],['missing']]) assert.throws(()=>f.scope({files}));
|
|
42
|
+
for(const extra of [{head:'HEAD'},{commit:'HEAD'},{parent:1}]) assert.throws(()=>f.scope(extra));
|
|
43
|
+
assert.throws(()=>f.scope({base:'missing'}));
|
|
44
|
+
});
|
|
45
|
+
test('links are read as links and directory links never disclose outside bytes',t=>{
|
|
46
|
+
const f=fixture(t), outside=mkdtempSync(join(tmpdir(),'review-outside-'));
|
|
47
|
+
t.after(()=>rmSync(outside,{recursive:true,force:true}));writeFileSync(join(outside,'secret'),'sensitive');
|
|
48
|
+
symlinkSync(join(outside,'secret'),join(f.root,'link'));
|
|
49
|
+
const scope=f.scope({files:['link']});assert.equal(scope.working_snapshot[0].after.kind,'symlink');
|
|
50
|
+
symlinkSync(outside,join(f.root,'directory'));
|
|
51
|
+
assert.throws(()=>f.scope({files:['directory/secret']}),/symlink directory/);
|
|
52
|
+
});
|
|
53
|
+
test('unmerged index and borrowed parent repository are rejected',t=>{
|
|
54
|
+
const f=fixture(t);f.git('checkout','-b','other');writeFileSync(join(f.root,'one.txt'),'other\n');f.git('commit','-am','other');
|
|
55
|
+
f.git('checkout','main');writeFileSync(join(f.root,'one.txt'),'main\n');f.git('commit','-am','main');
|
|
56
|
+
assert.throws(()=>f.git('merge','other'));assert.throws(()=>f.scope(),/unresolved index/);
|
|
57
|
+
f.git('merge','--abort');mkdirSync(join(f.root,'nested'));
|
|
58
|
+
assert.throws(()=>resolveReviewScope({repo:join(f.root,'nested'),mode:'working-tree',base:f.base}),/actual project Git root/);
|
|
59
|
+
});
|