@haposoft/cafekit 0.16.0-rc.1 → 0.16.0-rc.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/bin/__tests__/codex-hooks.test.js +355 -12
- package/bin/__tests__/codex-native.test.js +1 -1
- package/bin/__tests__/develop-contract.test.js +1374 -78
- package/bin/__tests__/installer-safety.test.js +43 -29
- package/bin/__tests__/opencode-spec-gate.test.js +791 -0
- package/bin/__tests__/package-inventory.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +707 -2
- package/package.json +1 -1
- package/src/claude/agents/code-auditor.md +8 -1
- package/src/claude/agents/spec-maker.md +107 -200
- package/src/claude/hooks/__tests__/completion-authority.test.js +720 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +511 -38
- package/src/claude/hooks/completion-authority-check.cjs +307 -0
- package/src/claude/hooks/completion-authority-state.cjs +525 -0
- package/src/claude/hooks/completion-authority.cjs +148 -0
- package/src/claude/hooks/session.cjs +33 -19
- package/src/claude/hooks/spec-gate.cjs +173 -61
- package/src/claude/hooks/spec-state.cjs +104 -45
- package/src/claude/migration-manifest.json +6 -1
- package/src/claude/scripts/provenance.cjs +315 -0
- package/src/claude/scripts/spec-resolver.cjs +450 -0
- package/src/claude/scripts/spec-scaffold.cjs +716 -41
- package/src/claude/scripts/validate-spec-output.cjs +355 -53
- package/src/claude/scripts/workflow-policy.cjs +1529 -162
- package/src/claude/settings/settings.json +8 -0
- package/src/claude/skills/backend-development/references/backend-security.md +52 -0
- package/src/claude/skills/code-review/SKILL.md +85 -66
- package/src/claude/skills/develop/SKILL.md +153 -285
- package/src/claude/skills/develop/references/quality-gate.md +112 -124
- package/src/claude/skills/develop/references/subagent-patterns.md +42 -37
- package/src/claude/skills/specs/SKILL.md +170 -381
- package/src/claude/skills/specs/rules/task-scoring-rubric.md +45 -46
- package/src/claude/skills/specs/rules/tasks-generation.md +49 -20
- package/src/claude/skills/specs/templates/spec-state.json +23 -2
- package/src/claude/skills/specs/templates/task.md +20 -0
- package/src/claude/skills/sync/SKILL.md +15 -8
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +141 -167
- package/src/codex/hooks/completion-authority-check.cjs +260 -0
- package/src/codex/hooks/completion-authority-state.cjs +525 -0
- package/src/codex/hooks/completion-authority.cjs +100 -0
- package/src/codex/hooks/lib/hook-context.cjs +42 -2
- package/src/codex/hooks/lib/spec-receipt.cjs +76 -43
- package/src/codex/hooks/lib/spec-utils.cjs +53 -27
- package/src/codex/hooks/session.cjs +2 -0
- package/src/codex/hooks/spec-gate.cjs +132 -20
- package/src/codex/hooks/spec-state.cjs +56 -10
- package/src/codex/hooks.json +10 -0
- package/src/opencode/AGENTS.md +4 -2
- package/src/opencode/plugins/spec-gate.ts +296 -122
- package/src/opencode/plugins/spec-state.ts +183 -116
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function specsDirectory(projectRoot, runtime) {
|
|
7
|
+
const rel = runtime && runtime.paths && typeof runtime.paths.specs === 'string' && runtime.paths.specs.trim()
|
|
8
|
+
? runtime.paths.specs.trim()
|
|
9
|
+
: 'specs';
|
|
10
|
+
const resolved = path.resolve(projectRoot, rel);
|
|
11
|
+
assertSpecsRootContained(projectRoot, resolved);
|
|
12
|
+
return resolved;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isMissingError(e) {
|
|
16
|
+
return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isPathInside(parentReal, childReal) {
|
|
20
|
+
if (parentReal === childReal) return false;
|
|
21
|
+
const rel = path.relative(parentReal, childReal);
|
|
22
|
+
return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPathInsideOrEqual(parent, child) {
|
|
26
|
+
const relative = path.relative(path.resolve(parent), path.resolve(child));
|
|
27
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertSpecsRootContained(projectRoot, specsDir) {
|
|
31
|
+
const projectPath = path.resolve(projectRoot);
|
|
32
|
+
const specsPath = path.resolve(specsDir);
|
|
33
|
+
if (!isPathInsideOrEqual(projectPath, specsPath)) {
|
|
34
|
+
throw candidateError('configured specs root escapes project root', 'SPECS_ROOT_OUTSIDE_PROJECT');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let projectReal;
|
|
38
|
+
try {
|
|
39
|
+
projectReal = fs.realpathSync(projectPath);
|
|
40
|
+
} catch (e) {
|
|
41
|
+
throw candidateError(`project root canonicalization error: ${e.message}`, 'PROJECT_ROOT_INVALID');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let probe = specsPath;
|
|
45
|
+
try {
|
|
46
|
+
while (!fs.existsSync(probe)) {
|
|
47
|
+
const parent = path.dirname(probe);
|
|
48
|
+
if (parent === probe) break;
|
|
49
|
+
probe = parent;
|
|
50
|
+
}
|
|
51
|
+
const canonicalProbe = fs.realpathSync(probe);
|
|
52
|
+
if (!isPathInsideOrEqual(projectReal, canonicalProbe)) {
|
|
53
|
+
throw candidateError('configured specs root traverses outside project root', 'SPECS_ROOT_OUTSIDE_PROJECT');
|
|
54
|
+
}
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (e && e.code === 'SPECS_ROOT_OUTSIDE_PROJECT') throw e;
|
|
57
|
+
throw candidateError(`configured specs root canonicalization error: ${e.message}`, 'SPECS_ROOT_INVALID');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isPlainObject(value) {
|
|
62
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function candidateError(reason, code = 'malformed') {
|
|
66
|
+
const error = new Error(reason);
|
|
67
|
+
error.code = code;
|
|
68
|
+
return error;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function inspectFeature(specsDir, canonicalSpecs, requestedName) {
|
|
72
|
+
const featureDir = path.join(specsDir, requestedName);
|
|
73
|
+
let featureLstat;
|
|
74
|
+
try {
|
|
75
|
+
featureLstat = fs.lstatSync(featureDir);
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (isMissingError(e)) return { missing: true };
|
|
78
|
+
throw candidateError(`feature lstat error: ${e.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let canonicalFeature;
|
|
82
|
+
try {
|
|
83
|
+
canonicalFeature = fs.realpathSync(featureDir);
|
|
84
|
+
if (!fs.statSync(featureDir).isDirectory()) {
|
|
85
|
+
throw candidateError('feature entry must be a directory');
|
|
86
|
+
}
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e && e.code) throw e;
|
|
89
|
+
throw candidateError(`feature canonicalization error: ${e.message}`);
|
|
90
|
+
}
|
|
91
|
+
if (canonicalFeature !== canonicalSpecs && !isPathInside(canonicalSpecs, canonicalFeature)) {
|
|
92
|
+
throw candidateError('feature symlink escapes specs root');
|
|
93
|
+
}
|
|
94
|
+
if (path.dirname(canonicalFeature) !== canonicalSpecs) {
|
|
95
|
+
throw candidateError('feature directory must resolve to a direct child of specs root');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const featureName = path.basename(canonicalFeature);
|
|
99
|
+
const specFile = path.join(featureDir, 'spec.json');
|
|
100
|
+
let specLstat;
|
|
101
|
+
try {
|
|
102
|
+
specLstat = fs.lstatSync(specFile);
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (isMissingError(e)) return { missingSpec: true, featureName, featureDir, canonicalFeature, specFile };
|
|
105
|
+
throw candidateError(`spec.json lstat error: ${e.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let canonicalSpecFile;
|
|
109
|
+
try {
|
|
110
|
+
canonicalSpecFile = fs.realpathSync(specFile);
|
|
111
|
+
if (!fs.statSync(specFile).isFile()) {
|
|
112
|
+
throw candidateError('spec.json must be a regular file');
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e && e.code) throw e;
|
|
116
|
+
throw candidateError(`spec.json canonicalization error: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
if (canonicalSpecFile !== canonicalSpecs && !isPathInside(canonicalSpecs, canonicalSpecFile)) {
|
|
119
|
+
throw candidateError('spec file symlink escapes specs root');
|
|
120
|
+
}
|
|
121
|
+
const expectedSpecFile = path.join(canonicalFeature, 'spec.json');
|
|
122
|
+
if (canonicalSpecFile !== expectedSpecFile) {
|
|
123
|
+
throw candidateError(`spec.json does not belong to feature directory ${featureName}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let spec;
|
|
127
|
+
try {
|
|
128
|
+
spec = JSON.parse(fs.readFileSync(canonicalSpecFile, 'utf8'));
|
|
129
|
+
} catch (e) {
|
|
130
|
+
throw candidateError(e.message);
|
|
131
|
+
}
|
|
132
|
+
if (!isPlainObject(spec)) throw candidateError('spec.json must contain a JSON object');
|
|
133
|
+
if (typeof spec.feature_name !== 'string' || spec.feature_name.trim() !== featureName) {
|
|
134
|
+
throw candidateError(`spec.json.feature_name must equal canonical feature name ${featureName}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
featureName,
|
|
139
|
+
spec,
|
|
140
|
+
specsDir,
|
|
141
|
+
featureDir: canonicalFeature,
|
|
142
|
+
specFile: canonicalSpecFile,
|
|
143
|
+
canonicalFeature,
|
|
144
|
+
canonicalSpecFile,
|
|
145
|
+
featureLstat,
|
|
146
|
+
specLstat,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function scanSpecs(specsDir) {
|
|
151
|
+
let canonicalSpecs;
|
|
152
|
+
let specsLstat = null;
|
|
153
|
+
try {
|
|
154
|
+
specsLstat = fs.lstatSync(specsDir);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (isMissingError(e)) return { active: [], candidates: [], invalid: [], canonicalSpecs: path.resolve(specsDir) };
|
|
157
|
+
return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs lstat error: ${e.message}`, specFile: specsDir }], canonicalSpecs: path.resolve(specsDir) };
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
canonicalSpecs = fs.realpathSync(specsDir);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs root canonicalization error: ${e.message}`, specFile: specsDir }], canonicalSpecs: path.resolve(specsDir) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let entries;
|
|
166
|
+
try {
|
|
167
|
+
entries = fs.readdirSync(specsDir, { withFileTypes: true });
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { active: [], candidates: [], invalid: [{ featureName: '<specs>', reason: `specs directory read error: ${e.message}`, specFile: specsDir }], canonicalSpecs };
|
|
170
|
+
}
|
|
171
|
+
const active = [];
|
|
172
|
+
const candidates = [];
|
|
173
|
+
const invalid = [];
|
|
174
|
+
const seenCanonicalFeatures = new Set();
|
|
175
|
+
const sorted = entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
176
|
+
for (const entry of sorted) {
|
|
177
|
+
try {
|
|
178
|
+
const candidate = inspectFeature(specsDir, canonicalSpecs, entry.name);
|
|
179
|
+
if (candidate.missing) {
|
|
180
|
+
invalid.push({
|
|
181
|
+
featureName: entry.name,
|
|
182
|
+
reason: 'feature entry disappeared during scan',
|
|
183
|
+
specFile: path.join(specsDir, entry.name, 'spec.json'),
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (candidate.missingSpec) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (seenCanonicalFeatures.has(candidate.canonicalFeature)) continue;
|
|
191
|
+
seenCanonicalFeatures.add(candidate.canonicalFeature);
|
|
192
|
+
candidates.push(candidate);
|
|
193
|
+
if (candidate.spec.status === 'in_progress' || candidate.spec.status === 'in-progress') {
|
|
194
|
+
active.push(candidate);
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
invalid.push({
|
|
198
|
+
featureName: entry.name,
|
|
199
|
+
reason: e.message,
|
|
200
|
+
specFile: path.join(specsDir, entry.name, 'spec.json'),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { active, candidates, invalid, canonicalSpecs };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function findAllSpecCandidates(projectRoot, runtime) {
|
|
208
|
+
let specsDir;
|
|
209
|
+
try {
|
|
210
|
+
specsDir = specsDirectory(projectRoot, runtime);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const wrapped = new Error(`Invalid spec candidates: <specs>: ${error.message}`);
|
|
213
|
+
wrapped.code = 'INVALID_SPECS';
|
|
214
|
+
wrapped.invalid = [{ featureName: '<specs>', reason: error.message, specFile: path.resolve(projectRoot, 'specs') }];
|
|
215
|
+
throw wrapped;
|
|
216
|
+
}
|
|
217
|
+
const scanned = scanSpecs(specsDir);
|
|
218
|
+
if (scanned.invalid.length > 0) {
|
|
219
|
+
const error = new Error(`Invalid spec candidates: ${scanned.invalid.map((item) => `${item.featureName}: ${item.reason}`).join('; ')}`);
|
|
220
|
+
error.code = 'INVALID_SPECS';
|
|
221
|
+
error.invalid = scanned.invalid;
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
return scanned.candidates.sort((left, right) => left.featureName.localeCompare(right.featureName));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function findAllActiveSpecs(projectRoot, runtime) {
|
|
228
|
+
let specsDir;
|
|
229
|
+
try {
|
|
230
|
+
specsDir = specsDirectory(projectRoot, runtime);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
const wrapped = new Error(`Invalid spec candidates: <specs>: ${error.message}`);
|
|
233
|
+
wrapped.code = 'INVALID_SPECS';
|
|
234
|
+
wrapped.invalid = [{ featureName: '<specs>', reason: error.message, specFile: path.resolve(projectRoot, 'specs') }];
|
|
235
|
+
throw wrapped;
|
|
236
|
+
}
|
|
237
|
+
const { active, invalid } = scanSpecs(specsDir);
|
|
238
|
+
if (invalid.length > 0) {
|
|
239
|
+
const error = new Error(`Invalid spec candidates: ${invalid.map((item) => `${item.featureName}: ${item.reason}`).join('; ')}`);
|
|
240
|
+
error.code = 'INVALID_SPECS';
|
|
241
|
+
error.invalid = invalid;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
return active;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isPathInsideLegacy(parent, child) {
|
|
248
|
+
const rel = path.relative(parent, child);
|
|
249
|
+
return rel !== '' && !rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function explicitTargetValue(value) {
|
|
253
|
+
if (typeof value !== 'string') return null;
|
|
254
|
+
const trimmed = value.trim();
|
|
255
|
+
return trimmed ? trimmed : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Normalize host-provided feature targets without making runtime adapters
|
|
260
|
+
* guess from the first active directory. The resolver remains the authority
|
|
261
|
+
* for containment, existence, JSON, and ambiguity checks.
|
|
262
|
+
*/
|
|
263
|
+
function extractExplicitTarget(...sources) {
|
|
264
|
+
for (const source of sources.flat()) {
|
|
265
|
+
if (!source || typeof source !== 'object') continue;
|
|
266
|
+
|
|
267
|
+
for (const key of ['explicitFeature', 'featureName', 'feature']) {
|
|
268
|
+
const value = explicitTargetValue(source[key]);
|
|
269
|
+
if (value) return { explicitFeature: value };
|
|
270
|
+
}
|
|
271
|
+
for (const key of ['explicitPath', 'specPath', 'spec_path', 'featurePath']) {
|
|
272
|
+
const value = explicitTargetValue(source[key]);
|
|
273
|
+
if (value) return { explicitPath: value };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const target = source.target;
|
|
277
|
+
if (target && typeof target === 'object') {
|
|
278
|
+
const nested = extractExplicitTarget(target);
|
|
279
|
+
if (nested) return nested;
|
|
280
|
+
}
|
|
281
|
+
const targetValue = explicitTargetValue(target);
|
|
282
|
+
if (targetValue) {
|
|
283
|
+
return /[\\/]/.test(targetValue) || /(?:^|[\\/])spec\.json$/.test(targetValue)
|
|
284
|
+
? { explicitPath: targetValue }
|
|
285
|
+
: { explicitFeature: targetValue };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function lstatOptional(p) {
|
|
292
|
+
try {
|
|
293
|
+
const s = fs.lstatSync(p);
|
|
294
|
+
return { exists: true, stat: s, isSymlink: s.isSymbolicLink(), error: null };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
if (isMissingError(e)) return { exists: false, stat: null, isSymlink: false, error: null };
|
|
297
|
+
return { exists: false, stat: null, isSymlink: false, error: e };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function resolveActiveSpec({ projectRoot, runtime, explicitFeature, explicitPath, target } = {}) {
|
|
302
|
+
if (!projectRoot) throw new TypeError('projectRoot required');
|
|
303
|
+
const rt = runtime || {};
|
|
304
|
+
const normalizedTarget = extractExplicitTarget(target);
|
|
305
|
+
explicitFeature = explicitFeature || normalizedTarget?.explicitFeature;
|
|
306
|
+
explicitPath = explicitPath || normalizedTarget?.explicitPath;
|
|
307
|
+
let specsDir;
|
|
308
|
+
try {
|
|
309
|
+
specsDir = specsDirectory(projectRoot, rt);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return {
|
|
312
|
+
error: explicitFeature || explicitPath ? 'explicit_malformed' : 'invalid_specs',
|
|
313
|
+
candidates: ['<specs>'],
|
|
314
|
+
explicitFeature,
|
|
315
|
+
explicitPath,
|
|
316
|
+
reason: error.message,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
let canonicalSpecs;
|
|
320
|
+
const specsLstat = lstatOptional(specsDir);
|
|
321
|
+
if (specsLstat.error) {
|
|
322
|
+
if (explicitFeature || explicitPath) {
|
|
323
|
+
return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs lstat error: ${specsLstat.error.message}` };
|
|
324
|
+
}
|
|
325
|
+
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}` };
|
|
326
|
+
}
|
|
327
|
+
if (!specsLstat.exists) {
|
|
328
|
+
canonicalSpecs = path.resolve(specsDir);
|
|
329
|
+
} else {
|
|
330
|
+
try {
|
|
331
|
+
canonicalSpecs = fs.realpathSync(specsDir);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
if (explicitFeature || explicitPath) {
|
|
334
|
+
return { error: 'explicit_malformed', explicitFeature, explicitPath, reason: `specs root canonicalization error: ${e.message}` };
|
|
335
|
+
}
|
|
336
|
+
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
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (explicitFeature) {
|
|
341
|
+
if (typeof explicitFeature !== 'string' || explicitFeature.trim() === '' || explicitFeature.includes('/') || explicitFeature.includes('\\') || explicitFeature.includes('..')) {
|
|
342
|
+
return { error: 'explicit_malformed', explicitFeature, reason: `malformed feature name: ${explicitFeature}` };
|
|
343
|
+
}
|
|
344
|
+
const featureDir = path.join(specsDir, explicitFeature);
|
|
345
|
+
const resolvedFeature = path.resolve(featureDir);
|
|
346
|
+
if (!isPathInsideLegacy(path.resolve(specsDir), resolvedFeature) && resolvedFeature !== path.resolve(specsDir)) {
|
|
347
|
+
return { error: 'explicit_malformed', explicitFeature, reason: 'feature path escapes specs root' };
|
|
348
|
+
}
|
|
349
|
+
if (path.relative(path.resolve(specsDir), resolvedFeature) !== explicitFeature) {
|
|
350
|
+
return { error: 'explicit_malformed', explicitFeature, reason: 'feature name does not canonicalize inside specs root' };
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const candidate = inspectFeature(specsDir, canonicalSpecs, explicitFeature);
|
|
354
|
+
if (candidate.missing || candidate.missingSpec) {
|
|
355
|
+
return { error: 'explicit_not_found', explicitFeature, reason: `spec not found for feature ${explicitFeature}` };
|
|
356
|
+
}
|
|
357
|
+
if (candidate.featureName !== explicitFeature) {
|
|
358
|
+
return {
|
|
359
|
+
error: 'explicit_malformed',
|
|
360
|
+
explicitFeature,
|
|
361
|
+
reason: `feature directory resolves to canonical feature ${candidate.featureName}`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
return candidate;
|
|
365
|
+
} catch (e) {
|
|
366
|
+
return { error: 'explicit_malformed', explicitFeature, reason: e.message };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (explicitPath) {
|
|
371
|
+
if (typeof explicitPath !== 'string' || explicitPath.trim() === '') {
|
|
372
|
+
return { error: 'explicit_malformed', explicitPath, reason: 'empty explicit path' };
|
|
373
|
+
}
|
|
374
|
+
let specFile = explicitPath;
|
|
375
|
+
if (!path.isAbsolute(specFile)) {
|
|
376
|
+
specFile = path.resolve(projectRoot, specFile);
|
|
377
|
+
} else {
|
|
378
|
+
specFile = path.resolve(specFile);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const st = fs.lstatSync(specFile);
|
|
382
|
+
if (st.isDirectory()) {
|
|
383
|
+
specFile = path.join(specFile, 'spec.json');
|
|
384
|
+
} else if (st.isSymbolicLink()) {
|
|
385
|
+
try {
|
|
386
|
+
if (fs.statSync(specFile).isDirectory()) specFile = path.join(specFile, 'spec.json');
|
|
387
|
+
} catch (e) {
|
|
388
|
+
if (!isMissingError(e)) throw e;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch (e) {
|
|
392
|
+
if (!isMissingError(e)) {
|
|
393
|
+
return { error: 'explicit_malformed', explicitPath, reason: `explicit path stat error: ${e.message}` };
|
|
394
|
+
}
|
|
395
|
+
// If lstat is missing, the candidate is handled as an explicit not-found below.
|
|
396
|
+
}
|
|
397
|
+
const featureDir = path.dirname(specFile);
|
|
398
|
+
if (!isPathInsideLegacy(path.resolve(specsDir), path.resolve(featureDir)) && path.resolve(featureDir) !== path.resolve(specsDir)) {
|
|
399
|
+
return { error: 'explicit_malformed', explicitPath, reason: 'explicit path escapes specs root' };
|
|
400
|
+
}
|
|
401
|
+
const rel = path.relative(path.resolve(specsDir), path.resolve(specFile));
|
|
402
|
+
const segs = rel.split(path.sep);
|
|
403
|
+
if (segs.length !== 2 || segs[1] !== 'spec.json') {
|
|
404
|
+
return { error: 'explicit_malformed', explicitPath, reason: 'explicit path must be <specs>/<feature>/spec.json or <specs>/<feature>' };
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const candidate = inspectFeature(specsDir, canonicalSpecs, segs[0]);
|
|
408
|
+
if (candidate.missing || candidate.missingSpec) {
|
|
409
|
+
return { error: 'explicit_not_found', explicitPath, reason: `spec not found at ${explicitPath}` };
|
|
410
|
+
}
|
|
411
|
+
if (candidate.featureName !== segs[0]) {
|
|
412
|
+
return {
|
|
413
|
+
error: 'explicit_malformed',
|
|
414
|
+
explicitPath,
|
|
415
|
+
reason: `explicit path resolves to canonical feature ${candidate.featureName}, not ${segs[0]}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
return candidate;
|
|
419
|
+
} catch (e) {
|
|
420
|
+
return { error: 'explicit_malformed', explicitPath, reason: e.message };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const { active, invalid } = scanSpecs(specsDir);
|
|
425
|
+
if (invalid.length > 0) {
|
|
426
|
+
return {
|
|
427
|
+
error: 'invalid_specs',
|
|
428
|
+
candidates: invalid.map((i) => i.featureName),
|
|
429
|
+
invalid,
|
|
430
|
+
reason: `Invalid spec JSON: ${invalid.map((i) => `${i.featureName}: ${i.reason}`).join('; ')}`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
active.sort((left, right) => left.featureName.localeCompare(right.featureName));
|
|
434
|
+
if (active.length === 0) return null;
|
|
435
|
+
if (active.length === 1) return active[0];
|
|
436
|
+
return {
|
|
437
|
+
error: 'multiple_active',
|
|
438
|
+
candidates: active.map((a) => a.featureName),
|
|
439
|
+
active,
|
|
440
|
+
reason: `Multiple active specs found: ${active.map((a) => a.featureName).join(', ')}. Provide explicit feature.`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
module.exports = {
|
|
445
|
+
specsDirectory,
|
|
446
|
+
findAllActiveSpecs,
|
|
447
|
+
findAllSpecCandidates,
|
|
448
|
+
extractExplicitTarget,
|
|
449
|
+
resolveActiveSpec,
|
|
450
|
+
};
|