@haposoft/cafekit 0.16.0-rc.3 → 0.16.0-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/__tests__/codex-hooks.test.js +444 -14
- package/bin/__tests__/codex-native.test.js +228 -7
- package/bin/__tests__/develop-contract.test.js +615 -185
- package/bin/__tests__/installer-safety.test.js +39 -0
- package/bin/__tests__/opencode-spec-gate.test.js +24 -16
- package/bin/__tests__/package-inventory.test.js +687 -13
- package/bin/__tests__/specs-v2-execution-closeout.test.js +421 -0
- package/bin/__tests__/specs-v2-policy-and-scaffold.test.js +601 -0
- package/bin/__tests__/specs-v2-validator-grounder-contract.test.js +952 -0
- package/bin/__tests__/test-runtime-dependency-closure.cjs +182 -0
- package/bin/__tests__/validator-grounding.test.js +1914 -221
- package/bin/lib/managed-writer.js +12 -3
- package/bin/phases/codex-runtime.js +17 -1
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +6 -0
- package/src/claude/agents/code-auditor.md +20 -0
- package/src/claude/agents/spec-maker.md +143 -99
- package/src/claude/hooks/__tests__/completion-authority.test.js +291 -58
- package/src/claude/hooks/__tests__/privacy-block.test.js +172 -0
- package/src/claude/hooks/__tests__/semantic-review-authority.test.js +257 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +115 -40
- package/src/claude/hooks/completion-authority-check.cjs +37 -288
- package/src/claude/hooks/completion-authority-state.cjs +10 -8
- package/src/claude/hooks/completion-authority.cjs +13 -7
- package/src/claude/hooks/docs-sync.cjs +5 -2
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +393 -0
- package/src/claude/hooks/lib/runtime-path-safety.cjs +119 -0
- package/src/claude/hooks/privacy-block.cjs +77 -30
- package/src/claude/hooks/semantic-review-authority.cjs +152 -0
- package/src/claude/hooks/spec-gate.cjs +47 -117
- package/src/claude/hooks/spec-state.cjs +8 -2
- package/src/claude/migration-manifest.json +9 -1
- package/src/claude/rules/state-sync.md +52 -21
- package/src/claude/rules/workflow.md +17 -9
- package/src/claude/scripts/spec-final-state.cjs +244 -0
- package/src/claude/scripts/spec-ground.cjs +535 -173
- package/src/claude/scripts/spec-readiness.cjs +118 -0
- package/src/claude/scripts/spec-receipt.cjs +162 -0
- package/src/claude/scripts/spec-resolver.cjs +67 -16
- package/src/claude/scripts/spec-scaffold.cjs +1354 -139
- package/src/claude/scripts/spec-semantic-model.cjs +396 -0
- package/src/claude/scripts/validate-spec-output.cjs +2782 -142
- package/src/claude/scripts/workflow-policy.cjs +501 -183
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/code-review/SKILL.md +12 -3
- package/src/claude/skills/code-review/references/spec-compliance-review.md +10 -2
- package/src/claude/skills/code-review/references/verification-gate.md +13 -0
- package/src/claude/skills/develop/SKILL.md +47 -60
- package/src/claude/skills/develop/references/parallel-waves.md +9 -6
- package/src/claude/skills/develop/references/quality-gate.md +19 -12
- package/src/claude/skills/specs/SKILL.md +183 -177
- package/src/claude/skills/specs/references/ask-user-question-gates.md +27 -18
- package/src/claude/skills/specs/references/cross-spec-dependency.md +8 -4
- package/src/claude/skills/specs/references/research-strategy.md +4 -1
- package/src/claude/skills/specs/references/review.md +200 -260
- package/src/claude/skills/specs/references/scope-inquiry.md +18 -5
- package/src/claude/skills/specs/rules/design-discovery-full.md +33 -81
- package/src/claude/skills/specs/rules/design-discovery-light.md +21 -42
- package/src/claude/skills/specs/rules/design-principles.md +136 -180
- package/src/claude/skills/specs/rules/design-review.md +42 -97
- package/src/claude/skills/specs/rules/ears-format.md +42 -50
- package/src/claude/skills/specs/rules/phase-decision-matrix.md +42 -41
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +2 -1
- package/src/claude/skills/specs/rules/tasks-generation.md +189 -300
- package/src/claude/skills/specs/templates/design.md +49 -263
- package/src/claude/skills/specs/templates/requirements.md +26 -36
- package/src/claude/skills/specs/templates/research.md +9 -98
- package/src/claude/skills/specs/templates/spec-state.json +23 -85
- package/src/claude/skills/specs/templates/task.md +30 -102
- package/src/claude/skills/sync/SKILL.md +33 -10
- package/src/claude/skills/sync/references/sync-protocols.md +31 -13
- package/src/claude/skills/test/SKILL.md +17 -7
- package/src/codex/AGENTS.md +6 -0
- package/src/codex/hooks/completion-authority-check.cjs +43 -240
- package/src/codex/hooks/completion-authority-state.cjs +10 -8
- package/src/codex/hooks/completion-authority.cjs +13 -7
- package/src/codex/hooks/lib/hook-context.cjs +16 -2
- package/src/codex/hooks/lib/privacy-command-analysis.cjs +3 -0
- package/src/codex/hooks/lib/privacy-state.cjs +38 -16
- package/src/codex/hooks/lib/runtime-path-safety.cjs +3 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +72 -134
- package/src/codex/hooks/lib/spec-utils.cjs +56 -5
- package/src/codex/hooks/privacy-approval.cjs +1 -1
- package/src/codex/hooks/privacy-block.cjs +67 -11
- package/src/codex/hooks/semantic-review-authority.cjs +130 -0
- package/src/codex/hooks/spec-gate.cjs +49 -34
- package/src/codex/hooks/spec-state.cjs +10 -3
- package/src/codex/hooks/state.cjs +2 -0
- package/src/codex/hooks.json +6 -1
- package/src/codex/rules/state-sync.md +47 -27
- package/src/common/AGENTS.md +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const SEMANTIC = require('./spec-semantic-model.cjs');
|
|
7
|
+
const VALIDATOR = require('./validate-spec-output.cjs');
|
|
8
|
+
const { canonicalProjectRoot, groundSpec } = require('./spec-ground.cjs');
|
|
9
|
+
|
|
10
|
+
function plain(value) {
|
|
11
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function exactKeys(value, fields) {
|
|
15
|
+
return plain(value)
|
|
16
|
+
&& JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readReviewResult(input) {
|
|
20
|
+
if (!exactKeys(input, ['reviewed_criteria', 'counterexamples'])) {
|
|
21
|
+
throw new Error('semantic review result fields must be exactly reviewed_criteria and counterexamples');
|
|
22
|
+
}
|
|
23
|
+
return JSON.parse(JSON.stringify(input));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function atomicReplace(file, bytes, io = fs) {
|
|
27
|
+
const stat = io.lstatSync(file);
|
|
28
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('spec.json must be a regular non-symlink file');
|
|
29
|
+
const original = io.readFileSync(file);
|
|
30
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.finalize-${process.pid}-${Date.now()}`);
|
|
31
|
+
let descriptor;
|
|
32
|
+
try {
|
|
33
|
+
descriptor = io.openSync(temporary, 'wx', stat.mode);
|
|
34
|
+
io.writeFileSync(descriptor, bytes);
|
|
35
|
+
io.fsyncSync(descriptor);
|
|
36
|
+
io.closeSync(descriptor);
|
|
37
|
+
descriptor = undefined;
|
|
38
|
+
if (process.env.CAFEKIT_FINALIZE_FAIL_BEFORE_RENAME === '1') throw new Error('injected finalization failure before rename');
|
|
39
|
+
io.renameSync(temporary, file);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (descriptor !== undefined) try { io.closeSync(descriptor); } catch {}
|
|
42
|
+
try { io.unlinkSync(temporary); } catch {}
|
|
43
|
+
if (!io.readFileSync(file).equals(original)) {
|
|
44
|
+
const rollback = `${temporary}-rollback`;
|
|
45
|
+
try {
|
|
46
|
+
io.writeFileSync(rollback, original, { flag: 'wx', mode: stat.mode });
|
|
47
|
+
io.renameSync(rollback, file);
|
|
48
|
+
} finally { try { io.unlinkSync(rollback); } catch {} }
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function finalizeReadiness({ specDir, reviewResult, projectRoot, io = fs }) {
|
|
55
|
+
const directory = io.realpathSync(path.resolve(specDir));
|
|
56
|
+
const root = projectRoot ? io.realpathSync(path.resolve(projectRoot)) : canonicalProjectRoot(directory);
|
|
57
|
+
const relative = path.relative(root, directory);
|
|
58
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
59
|
+
throw new Error('spec directory escapes the canonical project root');
|
|
60
|
+
}
|
|
61
|
+
const specFile = path.join(directory, 'spec.json');
|
|
62
|
+
const original = io.readFileSync(specFile);
|
|
63
|
+
let spec;
|
|
64
|
+
try { spec = JSON.parse(original); } catch (error) { throw new Error(`spec.json is invalid JSON (${error.message})`); }
|
|
65
|
+
if (!plain(spec) || spec.schema_version !== '2.1') throw new Error('readiness finalization accepts canonical schema 2.1 only');
|
|
66
|
+
const review = readReviewResult(reviewResult);
|
|
67
|
+
const projection = SEMANTIC.modelFromMarkdown(directory, spec);
|
|
68
|
+
if (projection.errors.length) throw new Error(`semantic projection failed (${projection.errors.join('; ')})`);
|
|
69
|
+
const candidate = JSON.parse(JSON.stringify(spec));
|
|
70
|
+
candidate.semantic_model = projection.model;
|
|
71
|
+
candidate.validation = {
|
|
72
|
+
status: 'completed',
|
|
73
|
+
semantic_review: {
|
|
74
|
+
status: 'not-run', semantic_digest: null, reviewed_criteria: [], counterexamples: [],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
candidate.ready_for_implementation = false;
|
|
78
|
+
const digestResult = VALIDATOR.computeSemanticDigest21(directory, candidate);
|
|
79
|
+
if (digestResult.errors.length) throw new Error(`semantic digest failed (${digestResult.errors.join('; ')})`);
|
|
80
|
+
candidate.validation.semantic_review = {
|
|
81
|
+
status: 'completed', semantic_digest: digestResult.digest,
|
|
82
|
+
reviewed_criteria: review.reviewed_criteria, counterexamples: review.counterexamples,
|
|
83
|
+
};
|
|
84
|
+
candidate.ready_for_implementation = true;
|
|
85
|
+
candidate.updated_at = new Date().toISOString();
|
|
86
|
+
const validation = VALIDATOR.validateSpec(directory, candidate);
|
|
87
|
+
if (validation.errors.length) throw new Error(`readiness validation failed (${validation.errors.join('; ')})`);
|
|
88
|
+
const grounding = groundSpec({ specDir: directory, root, spec: candidate });
|
|
89
|
+
if (grounding.errors.length) throw new Error(`readiness grounding failed (${grounding.errors.join('; ')})`);
|
|
90
|
+
const bytes = Buffer.from(`${JSON.stringify(candidate, null, 2)}\n`);
|
|
91
|
+
atomicReplace(specFile, bytes, io);
|
|
92
|
+
return { spec: candidate, semantic_digest: digestResult.digest };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function usage() {
|
|
96
|
+
process.stderr.write('Usage: node .claude/scripts/spec-readiness.cjs specs/<feature> --review-result <review.json>\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function main() {
|
|
100
|
+
const args = process.argv.slice(2);
|
|
101
|
+
const marker = args.indexOf('--review-result');
|
|
102
|
+
if (args.length !== 3 || marker !== 1) { usage(); process.exitCode = 2; return; }
|
|
103
|
+
try {
|
|
104
|
+
const reviewFile = path.resolve(args[2]);
|
|
105
|
+
const reviewStat = fs.lstatSync(reviewFile);
|
|
106
|
+
if (!reviewStat.isFile() || reviewStat.isSymbolicLink()) throw new Error('review result must be a regular non-symlink JSON file');
|
|
107
|
+
const reviewResult = JSON.parse(fs.readFileSync(reviewFile, 'utf8'));
|
|
108
|
+
const result = finalizeReadiness({ specDir: args[0], reviewResult });
|
|
109
|
+
process.stdout.write(`SPEC_READY ${args[0]} ${result.semantic_digest}\n`);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
process.stderr.write(`SPEC_NOT_READY ${error.message}\n`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { atomicReplace, finalizeReadiness, readReviewResult };
|
|
117
|
+
|
|
118
|
+
if (require.main === module) main();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const EVIDENCE_HEADINGS = new Set([
|
|
8
|
+
'Evidence',
|
|
9
|
+
'Task Test Plan & Verification Evidence',
|
|
10
|
+
'Verification & Evidence',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function inside(root, target) {
|
|
14
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
15
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function safeRead(root, relativePath) {
|
|
19
|
+
if (typeof relativePath !== 'string' || path.isAbsolute(relativePath)) return { status: 'unsafe' };
|
|
20
|
+
const segments = relativePath.split(/[\\/]+/);
|
|
21
|
+
if (segments.length === 0 || segments.some((segment) => !segment || segment === '.' || segment === '..')) return { status: 'unsafe' };
|
|
22
|
+
const resolvedRoot = path.resolve(root);
|
|
23
|
+
const target = path.resolve(resolvedRoot, ...segments);
|
|
24
|
+
if (!inside(resolvedRoot, target)) return { status: 'unsafe' };
|
|
25
|
+
try {
|
|
26
|
+
const canonicalRoot = fs.realpathSync(resolvedRoot);
|
|
27
|
+
let current = resolvedRoot;
|
|
28
|
+
for (const segment of segments) {
|
|
29
|
+
current = path.join(current, segment);
|
|
30
|
+
const stat = fs.lstatSync(current);
|
|
31
|
+
if (stat.isSymbolicLink()) return { status: 'unsafe' };
|
|
32
|
+
}
|
|
33
|
+
const canonicalTarget = fs.realpathSync(target);
|
|
34
|
+
if (!inside(canonicalRoot, canonicalTarget) || !fs.statSync(target).isFile()) return { status: 'unsafe' };
|
|
35
|
+
return { status: 'ok', path: canonicalTarget, bytes: fs.readFileSync(target) };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return { status: error && error.code === 'ENOENT' ? 'missing' : 'unsafe' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canonicalTaskReceiptPath(taskPath) {
|
|
42
|
+
if (typeof taskPath !== 'string') return null;
|
|
43
|
+
const match = taskPath.match(/^tasks\/(task-[A-Za-z0-9][A-Za-z0-9._-]*\.md)$/);
|
|
44
|
+
return match ? `receipts/${match[1]}` : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function evidenceBody(text, policy = {}) {
|
|
48
|
+
const lines = String(text || '').split('\n');
|
|
49
|
+
let start = -1;
|
|
50
|
+
let level = 0;
|
|
51
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
52
|
+
const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
53
|
+
if (match && EVIDENCE_HEADINGS.has(match[2])) {
|
|
54
|
+
start = index + 1;
|
|
55
|
+
level = match[1].length;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (start < 0) return null;
|
|
60
|
+
let end = lines.length;
|
|
61
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
62
|
+
const heading = lines[index].match(/^(#{1,6})\s+/);
|
|
63
|
+
const tap = typeof policy.isTapMetadataHeading === 'function' && policy.isTapMetadataHeading(lines[index]);
|
|
64
|
+
if (heading && heading[1].length <= level && !tap) { end = index; break; }
|
|
65
|
+
}
|
|
66
|
+
return lines.slice(start, end).join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function fieldValues(body, name) {
|
|
70
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
const matcher = new RegExp(`^\\s*${escaped}\\s*:\\s*(.*?)\\s*$`, 'i');
|
|
72
|
+
return String(body || '').split('\n').map((line) => line.match(matcher)).filter(Boolean).map((match) => match[1]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function concrete(value) {
|
|
76
|
+
return typeof value === 'string' && value.trim() !== '' && !/^(?:N\/A|NONE|PENDING|TBD|UNKNOWN|\{\{.*\}\})$/i.test(value.trim());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function plannedField(taskText, name) {
|
|
80
|
+
const plan = String(taskText || '').split(/^##\s+Verification Plan\s*$/m)[1];
|
|
81
|
+
if (!plan) return null;
|
|
82
|
+
const value = fieldValues(plan.split(/^##\s+/m)[0], `- **${name}**`)[0];
|
|
83
|
+
return value ? value.replace(/^`|`$/g, '').trim() : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function identity(body) {
|
|
87
|
+
const names = ['Verification', 'Command', 'Commands', 'Exit', 'Result', 'Base', 'Head', 'base_sha', 'head_sha'];
|
|
88
|
+
const fields = names.flatMap((name) => fieldValues(body, name).map((value) => [name.toLowerCase(), value.trim()]));
|
|
89
|
+
const artifacts = String(body || '').split('\n').filter((line) => /^\s*Artifacts?\s*:|^\s*Artifact\s+produced\b/i.test(line)).map((line) => line.trim());
|
|
90
|
+
return crypto.createHash('sha256').update(JSON.stringify({ fields, artifacts })).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function canonicalFailures(body, task, runtimeContext, policy) {
|
|
94
|
+
if (!policy || typeof policy.validateCanonicalReceipt !== 'function') return ['validator_unavailable'];
|
|
95
|
+
const options = typeof policy.receiptValidatorOptions === 'function'
|
|
96
|
+
? policy.receiptValidatorOptions(task || {}, { runtimeContext, requireProvenanceBinding: true })
|
|
97
|
+
: { requireProvenanceBinding: true };
|
|
98
|
+
return policy.validateCanonicalReceipt(body, options);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function separateMetadataFailures(body, taskPath, taskText) {
|
|
102
|
+
const failures = [];
|
|
103
|
+
const basename = path.posix.basename(taskPath);
|
|
104
|
+
if (fieldValues(body, 'Task').length !== 1 || fieldValues(body, 'Task')[0] !== basename) failures.push('task_identity');
|
|
105
|
+
if (fieldValues(body, 'Task path').length !== 1 || fieldValues(body, 'Task path')[0] !== taskPath) failures.push('task_identity');
|
|
106
|
+
if (!concrete(fieldValues(body, 'Expected')[0]) || !concrete(fieldValues(body, 'Observed')[0])) failures.push('expected_observed');
|
|
107
|
+
const plannedCommand = plannedField(taskText, 'Command');
|
|
108
|
+
const actualCommand = fieldValues(body, 'Command')[0] || fieldValues(body, 'Commands')[0];
|
|
109
|
+
if (plannedCommand && !/^N\/A\b/i.test(plannedCommand) && actualCommand !== plannedCommand) failures.push('command_identity');
|
|
110
|
+
for (const name of ['Negative path', 'Reachability']) {
|
|
111
|
+
const planned = plannedField(taskText, name);
|
|
112
|
+
if (planned && !/^(?:N\/A|none|not relevant)\b/i.test(planned) && !concrete(fieldValues(body, name)[0])) failures.push(name.toLowerCase().replace(' ', '_'));
|
|
113
|
+
}
|
|
114
|
+
return [...new Set(failures)];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readTaskProof(featureDir, taskPath, policy = {}) {
|
|
118
|
+
const receiptPath = canonicalTaskReceiptPath(taskPath);
|
|
119
|
+
if (typeof taskPath !== 'string' || !/^tasks\/[^/]+\.md$/.test(taskPath)) return { status: 'unsafe', taskPath, receiptPath: null };
|
|
120
|
+
const taskFile = safeRead(featureDir, taskPath);
|
|
121
|
+
if (taskFile.status !== 'ok') return { status: taskFile.status, taskPath, receiptPath };
|
|
122
|
+
const taskText = taskFile.bytes.toString('utf8');
|
|
123
|
+
const separate = receiptPath ? safeRead(featureDir, receiptPath) : { status: 'missing' };
|
|
124
|
+
const legacyBody = evidenceBody(taskText, policy);
|
|
125
|
+
if (separate.status === 'unsafe') return { status: 'unsafe', taskPath, receiptPath, taskFile };
|
|
126
|
+
if (separate.status === 'ok') {
|
|
127
|
+
const body = separate.bytes.toString('utf8');
|
|
128
|
+
return { status: 'ok', source: 'separate', body, receiptBytes: separate.bytes, receiptFile: separate, taskFile, taskText, taskPath, receiptPath, legacyBody };
|
|
129
|
+
}
|
|
130
|
+
if (legacyBody !== null) return { status: 'ok', source: 'legacy', body: legacyBody, receiptBytes: Buffer.from(legacyBody), taskFile, taskText, taskPath, receiptPath, legacyBody };
|
|
131
|
+
return { status: 'missing', taskPath, receiptPath, taskFile, taskText };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function checkTaskReceipt(featureDir, taskPath, task, runtimeContext, policy) {
|
|
135
|
+
const proof = readTaskProof(featureDir, taskPath, policy);
|
|
136
|
+
const failures = [];
|
|
137
|
+
if (proof.status !== 'ok') return { ...proof, failures: [proof.status === 'unsafe' ? 'unsafe_path' : 'missing_receipt'] };
|
|
138
|
+
const status = proof.taskText.split('\n').find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
|
|
139
|
+
if (!status || !/\bdone\b/i.test(status)) failures.push('task_status');
|
|
140
|
+
failures.push(...canonicalFailures(proof.body, task, runtimeContext, policy));
|
|
141
|
+
if (proof.source === 'separate') {
|
|
142
|
+
failures.push(...separateMetadataFailures(proof.body, taskPath, proof.taskText));
|
|
143
|
+
if (proof.legacyBody !== null
|
|
144
|
+
&& /^\s*Verification:\s*PASS\s*$/m.test(proof.legacyBody)
|
|
145
|
+
&& identity(proof.legacyBody) !== identity(proof.body)) failures.push('receipt_conflict');
|
|
146
|
+
}
|
|
147
|
+
const completedAt = task && task.completed_at;
|
|
148
|
+
if (typeof completedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(completedAt) || Number.isNaN(Date.parse(completedAt))) failures.push('completed_at');
|
|
149
|
+
return { ...proof, failures: [...new Set(failures)] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkFeatureReceipt(featureDir, runtimeContext, policy) {
|
|
153
|
+
const receipt = safeRead(featureDir, 'feature-receipt.md');
|
|
154
|
+
if (receipt.status !== 'ok') return { status: receipt.status, failures: [receipt.status === 'unsafe' ? 'unsafe_path' : 'missing_receipt'] };
|
|
155
|
+
const body = receipt.bytes.toString('utf8');
|
|
156
|
+
const failures = canonicalFailures(body, {}, runtimeContext, policy);
|
|
157
|
+
if (fieldValues(body, 'Feature').length !== 1 || fieldValues(body, 'Feature')[0] !== path.basename(featureDir)) failures.push('feature_identity');
|
|
158
|
+
if (!concrete(fieldValues(body, 'Expected')[0]) || !concrete(fieldValues(body, 'Observed')[0])) failures.push('expected_observed');
|
|
159
|
+
return { status: 'ok', source: 'feature', body, receiptBytes: receipt.bytes, receiptFile: receipt, failures: [...new Set(failures)] };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { canonicalTaskReceiptPath, checkFeatureReceipt, checkTaskReceipt, evidenceBody, readTaskProof, safeRead };
|
|
@@ -250,9 +250,7 @@ function isPathInsideLegacy(parent, child) {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
function explicitTargetValue(value) {
|
|
253
|
-
|
|
254
|
-
const trimmed = value.trim();
|
|
255
|
-
return trimmed ? trimmed : null;
|
|
253
|
+
return typeof value === 'string' ? value.trim() : value;
|
|
256
254
|
}
|
|
257
255
|
|
|
258
256
|
/**
|
|
@@ -265,12 +263,14 @@ function extractExplicitTarget(...sources) {
|
|
|
265
263
|
if (!source || typeof source !== 'object') continue;
|
|
266
264
|
|
|
267
265
|
for (const key of ['explicitFeature', 'featureName', 'feature']) {
|
|
268
|
-
|
|
269
|
-
|
|
266
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
267
|
+
return { explicitFeature: explicitTargetValue(source[key]) };
|
|
268
|
+
}
|
|
270
269
|
}
|
|
271
270
|
for (const key of ['explicitPath', 'specPath', 'spec_path', 'featurePath']) {
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
272
|
+
return { explicitPath: explicitTargetValue(source[key]) };
|
|
273
|
+
}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
const target = source.target;
|
|
@@ -278,8 +278,8 @@ function extractExplicitTarget(...sources) {
|
|
|
278
278
|
const nested = extractExplicitTarget(target);
|
|
279
279
|
if (nested) return nested;
|
|
280
280
|
}
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
if (Object.prototype.hasOwnProperty.call(source, 'target')) {
|
|
282
|
+
const targetValue = explicitTargetValue(target);
|
|
283
283
|
return /[\\/]/.test(targetValue) || /(?:^|[\\/])spec\.json$/.test(targetValue)
|
|
284
284
|
? { explicitPath: targetValue }
|
|
285
285
|
: { explicitFeature: targetValue };
|
|
@@ -302,14 +302,23 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
|
|
|
302
302
|
if (!projectRoot) throw new TypeError('projectRoot required');
|
|
303
303
|
const rt = runtime || {};
|
|
304
304
|
const normalizedTarget = extractExplicitTarget(target);
|
|
305
|
-
explicitFeature
|
|
306
|
-
|
|
305
|
+
if (explicitFeature === undefined && normalizedTarget
|
|
306
|
+
&& Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitFeature')) {
|
|
307
|
+
explicitFeature = normalizedTarget.explicitFeature;
|
|
308
|
+
}
|
|
309
|
+
if (explicitPath === undefined && normalizedTarget
|
|
310
|
+
&& Object.prototype.hasOwnProperty.call(normalizedTarget, 'explicitPath')) {
|
|
311
|
+
explicitPath = normalizedTarget.explicitPath;
|
|
312
|
+
}
|
|
313
|
+
const hasExplicitFeature = explicitFeature !== undefined && explicitFeature !== null;
|
|
314
|
+
const hasExplicitPath = explicitPath !== undefined && explicitPath !== null;
|
|
315
|
+
const hasExplicitTarget = hasExplicitFeature || hasExplicitPath;
|
|
307
316
|
let specsDir;
|
|
308
317
|
try {
|
|
309
318
|
specsDir = specsDirectory(projectRoot, rt);
|
|
310
319
|
} catch (error) {
|
|
311
320
|
return {
|
|
312
|
-
error:
|
|
321
|
+
error: hasExplicitTarget ? 'explicit_malformed' : 'invalid_specs',
|
|
313
322
|
candidates: ['<specs>'],
|
|
314
323
|
explicitFeature,
|
|
315
324
|
explicitPath,
|
|
@@ -319,7 +328,7 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
|
|
|
319
328
|
let canonicalSpecs;
|
|
320
329
|
const specsLstat = lstatOptional(specsDir);
|
|
321
330
|
if (specsLstat.error) {
|
|
322
|
-
if (
|
|
331
|
+
if (hasExplicitTarget) {
|
|
323
332
|
return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs lstat error: ${specsLstat.error.message}` };
|
|
324
333
|
}
|
|
325
334
|
return { error: 'invalid_specs', candidates: ['<specs>'], invalid: [{ featureName: '<specs>', reason: `specs lstat error: ${specsLstat.error.message}`, specFile: specsDir }], reason: `Invalid spec JSON: <specs>: specs lstat error: ${specsLstat.error.message}` };
|
|
@@ -330,14 +339,14 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
|
|
|
330
339
|
try {
|
|
331
340
|
canonicalSpecs = fs.realpathSync(specsDir);
|
|
332
341
|
} catch (e) {
|
|
333
|
-
if (
|
|
342
|
+
if (hasExplicitTarget) {
|
|
334
343
|
return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs root canonicalization error: ${e.message}` };
|
|
335
344
|
}
|
|
336
345
|
return { error: 'invalid_specs', candidates: ['<specs>'], invalid: [{ featureName: '<specs>', reason: `specs root canonicalization error: ${e.message}`, specFile: specsDir }], reason: `Invalid spec JSON: <specs>: specs root canonicalization error: ${e.message}` };
|
|
337
346
|
}
|
|
338
347
|
}
|
|
339
348
|
|
|
340
|
-
if (
|
|
349
|
+
if (hasExplicitFeature) {
|
|
341
350
|
if (typeof explicitFeature !== 'string' || explicitFeature.trim() === '' || explicitFeature.includes('/') || explicitFeature.includes('\\') || explicitFeature.includes('..')) {
|
|
342
351
|
return { error: 'explicit_malformed', explicitFeature, reason: `malformed feature name: ${explicitFeature}` };
|
|
343
352
|
}
|
|
@@ -367,7 +376,7 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
|
|
|
367
376
|
}
|
|
368
377
|
}
|
|
369
378
|
|
|
370
|
-
if (
|
|
379
|
+
if (hasExplicitPath) {
|
|
371
380
|
if (typeof explicitPath !== 'string' || explicitPath.trim() === '') {
|
|
372
381
|
return { error: 'explicit_malformed', explicitPath, reason: 'empty explicit path' };
|
|
373
382
|
}
|
|
@@ -441,10 +450,52 @@ function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath
|
|
|
441
450
|
};
|
|
442
451
|
}
|
|
443
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Resolve one persisted feature identity. Explicit host targets are inspected
|
|
455
|
+
* directly and never scan siblings; only the no-target path performs a global
|
|
456
|
+
* persisted-candidate scan and therefore owns ambiguity/invalid-sibling errors.
|
|
457
|
+
*/
|
|
458
|
+
function resolvePersistedSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
|
|
459
|
+
const normalized = extractExplicitTarget(
|
|
460
|
+
target,
|
|
461
|
+
explicitFeature !== undefined ? { explicitFeature } : null,
|
|
462
|
+
explicitPath !== undefined ? { explicitPath } : null,
|
|
463
|
+
);
|
|
464
|
+
if (normalized) {
|
|
465
|
+
const value = Object.prototype.hasOwnProperty.call(normalized, 'explicitFeature')
|
|
466
|
+
? normalized.explicitFeature
|
|
467
|
+
: normalized.explicitPath;
|
|
468
|
+
if (value === null || value === undefined) {
|
|
469
|
+
return { error: 'explicit_malformed', ...normalized, reason: 'explicit target must be a non-empty string' };
|
|
470
|
+
}
|
|
471
|
+
return resolveActiveSpec({ projectRoot, runtime, ...normalized });
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const candidates = findAllSpecCandidates(projectRoot, runtime);
|
|
475
|
+
if (candidates.length === 0) return null;
|
|
476
|
+
if (candidates.length === 1) return candidates[0];
|
|
477
|
+
return {
|
|
478
|
+
error: 'multiple_persisted',
|
|
479
|
+
candidates: candidates.map((candidate) => candidate.featureName),
|
|
480
|
+
reason: `Multiple persisted specs found: ${candidates.map((candidate) => candidate.featureName).join(', ')}. Provide explicit feature.`,
|
|
481
|
+
};
|
|
482
|
+
} catch (error) {
|
|
483
|
+
return {
|
|
484
|
+
error: 'invalid_specs',
|
|
485
|
+
candidates: Array.isArray(error.invalid)
|
|
486
|
+
? error.invalid.map((entry) => entry.featureName)
|
|
487
|
+
: ['<specs>'],
|
|
488
|
+
invalid: error.invalid || [],
|
|
489
|
+
reason: error.message,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
444
494
|
module.exports = {
|
|
445
495
|
specsDirectory,
|
|
446
496
|
findAllActiveSpecs,
|
|
447
497
|
findAllSpecCandidates,
|
|
448
498
|
extractExplicitTarget,
|
|
449
499
|
resolveActiveSpec,
|
|
500
|
+
resolvePersistedSpec,
|
|
450
501
|
};
|