gemstack-ai 1.1.2 → 1.2.0
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/.agents/skills/gemstack-plan/SKILL.md +2 -1
- package/.agents/skills/gemstack-qa/SKILL.md +3 -0
- package/.agents/skills/gemstack-ship/SKILL.md +5 -1
- package/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/.gemstack/state.json +7 -9
- package/CHANGELOG.md +32 -0
- package/README.md +13 -0
- package/RELEASE_NOTES.md +24 -0
- package/docs/architecture-consistency.md +14 -2
- package/docs/spec-driven-development.md +26 -0
- package/{gemstack-ai-1.1.2.tgz → gemstack-ai-1.2.0.tgz} +0 -0
- package/handoff.md +28 -15
- package/package.json +2 -2
- package/specs/007-mechanical-test-matrix-closure-evidence/.gemstack.json +5 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/closure.json +59 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/plan.md +484 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/spec.md +597 -0
- package/specs/007-mechanical-test-matrix-closure-evidence/tasks.md +536 -0
- package/specs/templates/plan.md +30 -0
- package/specs/templates/spec.md +18 -0
- package/specs/templates/tasks.md +9 -0
- package/src/cli.js +6 -0
- package/src/commands/collect.js +340 -0
- package/src/commands/ship.js +79 -0
- package/src/commands/verify.js +128 -6
- package/src/lib/closure-context.js +444 -0
- package/src/lib/runner-adapters.js +347 -0
- package/src/lib/test-matrix.js +187 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fssafe = require('../lib/filesystem-safe');
|
|
4
|
+
const logger = require('../lib/logger');
|
|
5
|
+
const { hashFile } = require('../lib/hasher');
|
|
6
|
+
const { readState } = require('../lib/state');
|
|
7
|
+
const {
|
|
8
|
+
extractTestMatrixBlock,
|
|
9
|
+
validateTestMatrix,
|
|
10
|
+
computeAcceptanceSignature
|
|
11
|
+
} = require('../lib/test-matrix');
|
|
12
|
+
const {
|
|
13
|
+
parsePlanBindings,
|
|
14
|
+
parsePlanGates,
|
|
15
|
+
parseTaskMetadata,
|
|
16
|
+
reconcileTaskTraceability,
|
|
17
|
+
resolveRelevantFiles,
|
|
18
|
+
computeContentAggregateHash,
|
|
19
|
+
resolveRepositoryContext,
|
|
20
|
+
computeClosureContextHash
|
|
21
|
+
} = require('../lib/closure-context');
|
|
22
|
+
const {
|
|
23
|
+
executeNodeTestRunner,
|
|
24
|
+
parseNodeTestTap,
|
|
25
|
+
reconcileTestRun,
|
|
26
|
+
executePackageScriptGate,
|
|
27
|
+
generateClosureManifest
|
|
28
|
+
} = require('../lib/runner-adapters');
|
|
29
|
+
|
|
30
|
+
module.exports = async (flags = {}) => {
|
|
31
|
+
const targetDir = flags.target || process.cwd();
|
|
32
|
+
const state = readState(targetDir);
|
|
33
|
+
|
|
34
|
+
if (!state || !state.active_spec) {
|
|
35
|
+
logger.error('No active spec found in state.json.');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const activeSpec = state.active_spec;
|
|
40
|
+
const specDir = fssafe.resolveSafe(targetDir, activeSpec);
|
|
41
|
+
const specFile = path.join(specDir, 'spec.md');
|
|
42
|
+
const planFile = path.join(specDir, 'plan.md');
|
|
43
|
+
const tasksFile = path.join(specDir, 'tasks.md');
|
|
44
|
+
|
|
45
|
+
if (!fs.existsSync(specFile)) {
|
|
46
|
+
logger.error(`spec.md not found at ${specFile}`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
logger.info(`Iniciando recolección mecánica de evidencias para: ${activeSpec}`);
|
|
51
|
+
|
|
52
|
+
// 1. Parse SPEC test matrix
|
|
53
|
+
const specContent = fs.readFileSync(specFile, 'utf8');
|
|
54
|
+
const { matrix, isLegacy } = extractTestMatrixBlock(specContent);
|
|
55
|
+
|
|
56
|
+
if (isLegacy) {
|
|
57
|
+
logger.info(`[LEGACY] Spec "${activeSpec}" opera en modo legacy sin matriz de pruebas.`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let canonicalMatrix;
|
|
62
|
+
let acceptanceSignature;
|
|
63
|
+
try {
|
|
64
|
+
canonicalMatrix = validateTestMatrix(matrix);
|
|
65
|
+
acceptanceSignature = computeAcceptanceSignature(canonicalMatrix);
|
|
66
|
+
logger.ok(`Matriz de pruebas validada (${canonicalMatrix.length} pruebas canónicas, signature: ${acceptanceSignature.slice(0, 12)}...)`);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
logger.error(`Error validando matriz de pruebas: ${e.message}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Parse PLAN bindings and gates
|
|
73
|
+
let planBindings = [];
|
|
74
|
+
let planGates = [];
|
|
75
|
+
if (fs.existsSync(planFile)) {
|
|
76
|
+
const planContent = fs.readFileSync(planFile, 'utf8');
|
|
77
|
+
try {
|
|
78
|
+
planBindings = parsePlanBindings(planContent);
|
|
79
|
+
planGates = parsePlanGates(planContent);
|
|
80
|
+
logger.ok(`Bindings de plan parseados (${planBindings.length} bindings, ${planGates.length} gates).`);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
logger.error(`Error parseando plan.md: ${e.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
logger.error(`plan.md no encontrado en ${planFile}`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3. Parse TASKS metadata & traceability
|
|
91
|
+
let tasks = [];
|
|
92
|
+
let traceability = {
|
|
93
|
+
summary: {
|
|
94
|
+
tasks_total: 0,
|
|
95
|
+
tasks_with_validation: 0,
|
|
96
|
+
tasks_documentation_only: 0,
|
|
97
|
+
unmapped_canonical_tests: []
|
|
98
|
+
},
|
|
99
|
+
unmappedCanonical: [],
|
|
100
|
+
reverseMap: {}
|
|
101
|
+
};
|
|
102
|
+
if (fs.existsSync(tasksFile)) {
|
|
103
|
+
const tasksContent = fs.readFileSync(tasksFile, 'utf8');
|
|
104
|
+
try {
|
|
105
|
+
tasks = parseTaskMetadata(tasksContent);
|
|
106
|
+
traceability = reconcileTaskTraceability(canonicalMatrix, tasks);
|
|
107
|
+
if (traceability.unmappedCanonical.length === 0) {
|
|
108
|
+
logger.ok(`Trazabilidad TASK <-> TEST confirmada (${tasks.length} tareas totales).`);
|
|
109
|
+
} else {
|
|
110
|
+
logger.warn(`Pruebas canónicas no mapeadas a tareas: ${traceability.unmappedCanonical.join(', ')}`);
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
logger.error(`Error parseando tasks.md: ${e.message}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. Execute Test Runner on bound files
|
|
119
|
+
const boundTestFiles = Array.from(new Set(planBindings.map(b => b.file)));
|
|
120
|
+
logger.info(`Ejecutando runner sobre ${boundTestFiles.length} archivo(s) de prueba vinculados...`);
|
|
121
|
+
|
|
122
|
+
const startTime = Date.now();
|
|
123
|
+
let runnerResult;
|
|
124
|
+
try {
|
|
125
|
+
runnerResult = await executeNodeTestRunner(targetDir, boundTestFiles);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
logger.error(`Fallo crítico ejecutando test runner: ${e.message}`);
|
|
128
|
+
runnerResult = { exitCode: 1, stdout: '', stderr: e.message };
|
|
129
|
+
}
|
|
130
|
+
const runnerDuration = Date.now() - startTime;
|
|
131
|
+
|
|
132
|
+
const tapSummary = parseNodeTestTap(runnerResult.stdout);
|
|
133
|
+
const reconciliation = reconcileTestRun(canonicalMatrix, planBindings, tapSummary.tests);
|
|
134
|
+
|
|
135
|
+
logger.ok(`Runner completado (exitCode: ${runnerResult.exitCode}, physical: ${tapSummary.physicalTotal}, passed: ${tapSummary.passed}, failed: ${tapSummary.failed})`);
|
|
136
|
+
|
|
137
|
+
// 5. Execute Required & Supplemental Gates
|
|
138
|
+
const requiredGateResults = {};
|
|
139
|
+
const supplementalGateResults = {};
|
|
140
|
+
const blockers = [];
|
|
141
|
+
const warnings = [];
|
|
142
|
+
|
|
143
|
+
for (const gate of planGates) {
|
|
144
|
+
logger.info(`Ejecutando gate "${gate.id}" (${gate.type}: ${gate.script})...`);
|
|
145
|
+
if (gate.type === 'PACKAGE_SCRIPT') {
|
|
146
|
+
const gateRes = await executePackageScriptGate(targetDir, gate);
|
|
147
|
+
const passed = gateRes.exitCode === 0;
|
|
148
|
+
const status = passed ? 'PASS' : 'FAIL';
|
|
149
|
+
|
|
150
|
+
if (gate.requirement === 'REQUIRED') {
|
|
151
|
+
requiredGateResults[gate.id] = status;
|
|
152
|
+
if (!passed) {
|
|
153
|
+
blockers.push({
|
|
154
|
+
code: 'REQUIRED_GATE_FAILED',
|
|
155
|
+
gate: gate.id,
|
|
156
|
+
message: `Required gate "${gate.id}" failed with exit code ${gateRes.exitCode}`,
|
|
157
|
+
waivable: gate.waivable
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
supplementalGateResults[gate.id] = status;
|
|
162
|
+
if (!passed) {
|
|
163
|
+
warnings.push({
|
|
164
|
+
code: 'SUPPLEMENTAL_GATE_FAILED',
|
|
165
|
+
gate: gate.id,
|
|
166
|
+
message: `Supplemental gate "${gate.id}" failed with exit code ${gateRes.exitCode}`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check reconciliation anomalies
|
|
174
|
+
if (!reconciliation.mathValid) {
|
|
175
|
+
blockers.push({
|
|
176
|
+
code: 'CLOSURE_RECONCILIATION_FAILURE',
|
|
177
|
+
message: 'Arithmetic count mismatch between executed canonical/supporting events and total physical events'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const m of reconciliation.missing) {
|
|
182
|
+
blockers.push({
|
|
183
|
+
code: 'REQUIRED_TEST_MISSING',
|
|
184
|
+
canonicalId: m,
|
|
185
|
+
message: `Required canonical test "${m}" is missing a physical binding in plan.md`
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const ne of reconciliation.notExecuted) {
|
|
190
|
+
blockers.push({
|
|
191
|
+
code: 'REQUIRED_TEST_NOT_EXECUTED',
|
|
192
|
+
canonicalId: ne,
|
|
193
|
+
message: `Required canonical test "${ne}" was bound in plan.md but not executed in test runner`
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const ph of reconciliation.phantoms) {
|
|
198
|
+
blockers.push({
|
|
199
|
+
code: 'PHANTOM_TEST',
|
|
200
|
+
canonicalId: ph,
|
|
201
|
+
message: `Claimed test "${ph}" was absent from runner execution traces`
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const orp of reconciliation.orphans) {
|
|
206
|
+
blockers.push({
|
|
207
|
+
code: 'ORPHAN_TEST',
|
|
208
|
+
canonicalId: orp,
|
|
209
|
+
message: `Physical test claimed canonical ID "${orp}" which is absent from spec.md matrix`
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const dup of reconciliation.duplicates) {
|
|
214
|
+
blockers.push({
|
|
215
|
+
code: 'DUPLICATE_TEST_BINDING',
|
|
216
|
+
canonicalId: dup,
|
|
217
|
+
message: `Multiple physical tests claimed the same canonical ID "${dup}"`
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check physical failures
|
|
222
|
+
const executedCanonicalPassed = tapSummary.tests.filter(t => t.id && t.rawOutcome === 'PASS');
|
|
223
|
+
const executedCanonicalFailed = tapSummary.tests.filter(t => t.id && t.rawOutcome === 'FAIL');
|
|
224
|
+
for (const f of executedCanonicalFailed) {
|
|
225
|
+
blockers.push({
|
|
226
|
+
code: 'REQUIRED_TEST_FAILED',
|
|
227
|
+
canonicalId: f.id,
|
|
228
|
+
message: `Required canonical test "${f.id}" failed during execution`
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Transfer traceability unmapped errors if any
|
|
233
|
+
for (const unmappedId of traceability.unmappedCanonical) {
|
|
234
|
+
blockers.push({
|
|
235
|
+
code: 'UNMAPPED_CANONICAL_TEST',
|
|
236
|
+
canonicalId: unmappedId,
|
|
237
|
+
message: `Required canonical test "${unmappedId}" is not bound to any implementation task`
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 6. Compute Closure Context Hash & Manifest
|
|
242
|
+
const repoContext = resolveRepositoryContext(targetDir);
|
|
243
|
+
|
|
244
|
+
const phaseHashes = {
|
|
245
|
+
spec: hashFile(specFile),
|
|
246
|
+
plan: hashFile(planFile),
|
|
247
|
+
tasks: fs.existsSync(tasksFile) ? hashFile(tasksFile) : null
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const relevantFiles = resolveRelevantFiles(targetDir, activeSpec, planBindings, tasks, planGates);
|
|
251
|
+
const relevantFilesDigest = computeContentAggregateHash(targetDir, relevantFiles);
|
|
252
|
+
|
|
253
|
+
const testFilesHash = computeContentAggregateHash(targetDir, boundTestFiles);
|
|
254
|
+
const implementationFiles = Array.from(new Set(tasks.flatMap(t => t.files || [])))
|
|
255
|
+
.filter(f => !f.endsWith('closure.json'));
|
|
256
|
+
const implementationContextHash = computeContentAggregateHash(targetDir, implementationFiles);
|
|
257
|
+
const requiredGateDefinitionHash = computeContentAggregateHash(targetDir, ['package.json']);
|
|
258
|
+
|
|
259
|
+
const contextObj = {
|
|
260
|
+
version: 1,
|
|
261
|
+
repository: repoContext,
|
|
262
|
+
phase_hashes: phaseHashes,
|
|
263
|
+
acceptance_signature: acceptanceSignature,
|
|
264
|
+
test_files_hash: testFilesHash,
|
|
265
|
+
implementation_context_hash: implementationContextHash,
|
|
266
|
+
required_gate_definition_hash: requiredGateDefinitionHash
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const closureContextHash = computeClosureContextHash(contextObj);
|
|
270
|
+
|
|
271
|
+
// Status determination
|
|
272
|
+
let status = 'VERIFIED';
|
|
273
|
+
if (blockers.length > 0) {
|
|
274
|
+
status = 'BLOCKED';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const manifestData = {
|
|
278
|
+
feature: activeSpec,
|
|
279
|
+
generated_at: new Date().toISOString(),
|
|
280
|
+
status,
|
|
281
|
+
closure_context: {
|
|
282
|
+
closure_context_hash: closureContextHash,
|
|
283
|
+
repository_type: repoContext.type,
|
|
284
|
+
git_commit: repoContext.commit,
|
|
285
|
+
working_tree_clean: repoContext.working_tree_clean,
|
|
286
|
+
relevant_files_digest: relevantFilesDigest
|
|
287
|
+
},
|
|
288
|
+
acceptance_signature: acceptanceSignature,
|
|
289
|
+
canonical_summary: {
|
|
290
|
+
required_total: canonicalMatrix.filter(c => c.gate === 'REQUIRED').length,
|
|
291
|
+
required_passed: executedCanonicalPassed.length,
|
|
292
|
+
supplemental_total: canonicalMatrix.filter(c => c.gate === 'SUPPLEMENTAL').length,
|
|
293
|
+
supplemental_passed: 0
|
|
294
|
+
},
|
|
295
|
+
physical_summary: {
|
|
296
|
+
supporting_total: reconciliation.supportingCount,
|
|
297
|
+
supporting_passed: reconciliation.supportingCount - (tapSummary.failed - executedCanonicalFailed.length),
|
|
298
|
+
total_executed: tapSummary.physicalTotal,
|
|
299
|
+
total_passed: tapSummary.passed,
|
|
300
|
+
total_failed: tapSummary.failed,
|
|
301
|
+
total_skipped: tapSummary.skipped + tapSummary.todo + tapSummary.cancelled
|
|
302
|
+
},
|
|
303
|
+
reconciliation: {
|
|
304
|
+
math_valid: reconciliation.mathValid,
|
|
305
|
+
phantoms_detected: reconciliation.phantoms.length,
|
|
306
|
+
orphans_detected: reconciliation.orphans.length,
|
|
307
|
+
missing_canonical_ids: reconciliation.missing
|
|
308
|
+
},
|
|
309
|
+
task_traceability_summary: {
|
|
310
|
+
tasks_total: tasks.length,
|
|
311
|
+
tasks_with_validation: traceability.summary.tasks_with_validation,
|
|
312
|
+
tasks_documentation_only: traceability.summary.tasks_documentation_only,
|
|
313
|
+
unmapped_canonical_tests: traceability.unmappedCanonical
|
|
314
|
+
},
|
|
315
|
+
required_gates: requiredGateResults,
|
|
316
|
+
supplemental_gates: supplementalGateResults,
|
|
317
|
+
exceptions: [],
|
|
318
|
+
evidence_sources: [
|
|
319
|
+
{
|
|
320
|
+
type: 'PACKAGE_SCRIPT',
|
|
321
|
+
script: 'test',
|
|
322
|
+
runner: 'node:test',
|
|
323
|
+
exit_code: runnerResult.exitCode,
|
|
324
|
+
duration_ms: runnerDuration
|
|
325
|
+
}
|
|
326
|
+
],
|
|
327
|
+
blockers,
|
|
328
|
+
warnings
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
if (flags.dryRun) {
|
|
332
|
+
logger.info('[DRY RUN] Manifest generado en memoria (no persistido en disco):');
|
|
333
|
+
console.log(JSON.stringify(manifestData, null, 2));
|
|
334
|
+
return manifestData;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const writtenManifest = generateClosureManifest(specDir, manifestData);
|
|
338
|
+
logger.ok(`Manifest de cierre persistido en: ${path.join(specDir, 'closure.json')} (status: ${status})`);
|
|
339
|
+
return writtenManifest;
|
|
340
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fssafe = require('../lib/filesystem-safe');
|
|
4
|
+
const logger = require('../lib/logger');
|
|
5
|
+
const { readState, writeStateAtomic } = require('../lib/state');
|
|
6
|
+
const { extractTestMatrixBlock } = require('../lib/test-matrix');
|
|
7
|
+
|
|
8
|
+
module.exports = async (flags = {}) => {
|
|
9
|
+
const targetDir = flags.target || process.cwd();
|
|
10
|
+
const state = readState(targetDir);
|
|
11
|
+
|
|
12
|
+
if (!state || !state.active_spec) {
|
|
13
|
+
const err = new Error('No active spec found to ship');
|
|
14
|
+
err.code = 'NO_ACTIVE_SPEC';
|
|
15
|
+
throw err;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const activeSpec = state.active_spec;
|
|
19
|
+
const specDir = fssafe.resolveSafe(targetDir, activeSpec);
|
|
20
|
+
const specFile = path.join(specDir, 'spec.md');
|
|
21
|
+
|
|
22
|
+
if (!fs.existsSync(specFile)) {
|
|
23
|
+
const err = new Error(`spec.md not found at ${specFile}`);
|
|
24
|
+
err.code = 'SPEC_NOT_FOUND';
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const specContent = fs.readFileSync(specFile, 'utf8');
|
|
29
|
+
const { matrix, isLegacy } = extractTestMatrixBlock(specContent);
|
|
30
|
+
|
|
31
|
+
if (isLegacy) {
|
|
32
|
+
logger.info(`[LEGACY] Spec "${activeSpec}" opera en modo legacy sin matriz de pruebas.`);
|
|
33
|
+
// Transition lifecycle
|
|
34
|
+
state.last_completed_feature = activeSpec;
|
|
35
|
+
state.active_spec = null;
|
|
36
|
+
state.current_phase = 'shipped';
|
|
37
|
+
state.status = 'SHIPPED';
|
|
38
|
+
state.last_update = new Date().toISOString();
|
|
39
|
+
writeStateAtomic(targetDir, state);
|
|
40
|
+
logger.ok(`Feature "${activeSpec}" enviada exitosamente en modo legacy.`);
|
|
41
|
+
return { shipped: true, legacy: true, feature: activeSpec };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Structured Upgrade B closure verification
|
|
45
|
+
const closurePath = path.join(specDir, 'closure.json');
|
|
46
|
+
if (!fs.existsSync(closurePath)) {
|
|
47
|
+
const err = new Error(`Cannot ship "${activeSpec}": closure.json not found. Run "gemstack collect" first.`);
|
|
48
|
+
err.code = 'CLOSURE_MANIFEST_MISSING';
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let manifest;
|
|
53
|
+
try {
|
|
54
|
+
manifest = JSON.parse(fs.readFileSync(closurePath, 'utf8'));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
const err = new Error(`Failed to parse closure.json: ${e.message}`);
|
|
57
|
+
err.code = 'CLOSURE_MANIFEST_INVALID';
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const allowedStatuses = ['VERIFIED', 'VERIFIED_WITH_EXCEPTIONS'];
|
|
62
|
+
if (!allowedStatuses.includes(manifest.status)) {
|
|
63
|
+
const blockersSummary = (manifest.blockers || []).map(b => b.code || b.message).join(', ');
|
|
64
|
+
const err = new Error(`Cannot ship "${activeSpec}": closure status is "${manifest.status}". Blockers: ${blockersSummary || 'none'}`);
|
|
65
|
+
err.code = 'CLOSURE_NOT_VERIFIED';
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Transition lifecycle to SHIPPED
|
|
70
|
+
state.last_completed_feature = activeSpec;
|
|
71
|
+
state.active_spec = null;
|
|
72
|
+
state.current_phase = 'shipped';
|
|
73
|
+
state.status = 'SHIPPED';
|
|
74
|
+
state.last_update = new Date().toISOString();
|
|
75
|
+
writeStateAtomic(targetDir, state);
|
|
76
|
+
|
|
77
|
+
logger.ok(`Feature "${activeSpec}" verificada mecánicamente y enviada exitosamente (status: ${manifest.status}).`);
|
|
78
|
+
return { shipped: true, legacy: false, feature: activeSpec, status: manifest.status };
|
|
79
|
+
};
|
package/src/commands/verify.js
CHANGED
|
@@ -13,7 +13,7 @@ module.exports = async (flags) => {
|
|
|
13
13
|
let totalWarnings = 0;
|
|
14
14
|
|
|
15
15
|
// 1. Verificación Estructural y Manifest
|
|
16
|
-
logger.info('--- 1/
|
|
16
|
+
logger.info('--- 1/6 Verificación Estructural (Archivos Base) ---');
|
|
17
17
|
const manifestPath = fssafe.resolveSafe(targetDir, '.gemstack/manifest.json');
|
|
18
18
|
if (!fs.existsSync(manifestPath)) {
|
|
19
19
|
logger.warn('Manifest no encontrado (.gemstack/manifest.json). Es posible que Gemstack no esté inicializado en este directorio.');
|
|
@@ -58,7 +58,7 @@ module.exports = async (flags) => {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// 2. Verificación de Memoria (handoff.md)
|
|
61
|
-
logger.info('--- 2/
|
|
61
|
+
logger.info('--- 2/6 Verificación de Memoria e Integridad de Handoff ---');
|
|
62
62
|
const handoffPath = fssafe.resolveSafe(targetDir, 'handoff.md');
|
|
63
63
|
if (!fs.existsSync(handoffPath)) {
|
|
64
64
|
logger.error('handoff.md no existe en la raíz. La memoria de sesión es obligatoria.');
|
|
@@ -90,7 +90,7 @@ module.exports = async (flags) => {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// 3. Consistencia de Estado Local (.gemstack/state.json)
|
|
93
|
-
logger.info('--- 3/
|
|
93
|
+
logger.info('--- 3/6 Verificación de Estado Local (.gemstack/state.json) ---');
|
|
94
94
|
const statePath = fssafe.resolveSafe(targetDir, '.gemstack/state.json');
|
|
95
95
|
let loadedState = null;
|
|
96
96
|
if (!fs.existsSync(statePath)) {
|
|
@@ -119,7 +119,7 @@ module.exports = async (flags) => {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// 4. Consistencia de Arquitectura y Hashes de Fase (Upgrade A)
|
|
122
|
-
logger.info('--- 4/
|
|
122
|
+
logger.info('--- 4/6 Verificación de Consistencia de Arquitectura y Hashes de Fase ---');
|
|
123
123
|
if (loadedState && loadedState.active_spec) {
|
|
124
124
|
try {
|
|
125
125
|
const { hashFile } = require('../lib/hasher');
|
|
@@ -254,8 +254,130 @@ module.exports = async (flags) => {
|
|
|
254
254
|
logger.ok('Sin spec activa configurada para verificación de contratos.');
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
// 5.
|
|
258
|
-
logger.info('--- 5/
|
|
257
|
+
// 5. Verificación de Evidencia de Cierre Mecánico (Upgrade B - Read-Only)
|
|
258
|
+
logger.info('--- 5/6 Verificación de Evidencia de Cierre Mecánico (Read-Only) ---');
|
|
259
|
+
if (loadedState && loadedState.active_spec) {
|
|
260
|
+
try {
|
|
261
|
+
const { extractTestMatrixBlock } = require('../lib/test-matrix');
|
|
262
|
+
const specFile = fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'spec.md'));
|
|
263
|
+
|
|
264
|
+
if (!fs.existsSync(specFile)) {
|
|
265
|
+
logger.ok(`Modo legacy: no existe spec.md en ${loadedState.active_spec}`);
|
|
266
|
+
} else {
|
|
267
|
+
const specContent = fs.readFileSync(specFile, 'utf8');
|
|
268
|
+
const { isLegacy } = extractTestMatrixBlock(specContent);
|
|
269
|
+
|
|
270
|
+
if (isLegacy) {
|
|
271
|
+
logger.info(`[LEGACY] Spec "${loadedState.active_spec}" opera en modo legacy sin matriz de pruebas.`);
|
|
272
|
+
} else {
|
|
273
|
+
// Feature structured: read closure.json in strictly read-only mode
|
|
274
|
+
const specDir = fssafe.resolveSafe(targetDir, loadedState.active_spec);
|
|
275
|
+
const closurePath = path.join(specDir, 'closure.json');
|
|
276
|
+
|
|
277
|
+
if (!fs.existsSync(closurePath)) {
|
|
278
|
+
logger.error(`[CLOSURE_MANIFEST_MISSING] closure.json no existe en ${loadedState.active_spec}. Ejecuta "gemstack collect" para generar la evidencia mecánica.`);
|
|
279
|
+
totalErrors++;
|
|
280
|
+
} else {
|
|
281
|
+
let manifest;
|
|
282
|
+
try {
|
|
283
|
+
manifest = JSON.parse(fs.readFileSync(closurePath, 'utf8'));
|
|
284
|
+
} catch (e) {
|
|
285
|
+
logger.error(`[CLOSURE_MANIFEST_INVALID] closure.json tiene formato JSON inválido: ${e.message}`);
|
|
286
|
+
totalErrors++;
|
|
287
|
+
manifest = null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (manifest) {
|
|
291
|
+
// Recompute closureContextHash in-memory without modifying any file
|
|
292
|
+
const {
|
|
293
|
+
validateTestMatrix,
|
|
294
|
+
computeAcceptanceSignature
|
|
295
|
+
} = require('../lib/test-matrix');
|
|
296
|
+
const {
|
|
297
|
+
parsePlanBindings,
|
|
298
|
+
parsePlanGates,
|
|
299
|
+
parseTaskMetadata,
|
|
300
|
+
computeContentAggregateHash,
|
|
301
|
+
resolveRepositoryContext,
|
|
302
|
+
computeClosureContextHash
|
|
303
|
+
} = require('../lib/closure-context');
|
|
304
|
+
const { hashFile } = require('../lib/hasher');
|
|
305
|
+
|
|
306
|
+
const planFile = path.join(specDir, 'plan.md');
|
|
307
|
+
const tasksFile = path.join(specDir, 'tasks.md');
|
|
308
|
+
|
|
309
|
+
let planBindings = [];
|
|
310
|
+
let planGates = [];
|
|
311
|
+
if (fs.existsSync(planFile)) {
|
|
312
|
+
const planContent = fs.readFileSync(planFile, 'utf8');
|
|
313
|
+
planBindings = parsePlanBindings(planContent);
|
|
314
|
+
planGates = parsePlanGates(planContent);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let tasks = [];
|
|
318
|
+
if (fs.existsSync(tasksFile)) {
|
|
319
|
+
const tasksContent = fs.readFileSync(tasksFile, 'utf8');
|
|
320
|
+
tasks = parseTaskMetadata(tasksContent);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { matrix } = extractTestMatrixBlock(specContent);
|
|
324
|
+
const canonicalMatrix = validateTestMatrix(matrix);
|
|
325
|
+
const acceptanceSignature = computeAcceptanceSignature(canonicalMatrix);
|
|
326
|
+
|
|
327
|
+
const repoContext = resolveRepositoryContext(targetDir);
|
|
328
|
+
const phaseHashes = {
|
|
329
|
+
spec: hashFile(specFile),
|
|
330
|
+
plan: fs.existsSync(planFile) ? hashFile(planFile) : null,
|
|
331
|
+
tasks: fs.existsSync(tasksFile) ? hashFile(tasksFile) : null
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const boundTestFiles = Array.from(new Set(planBindings.map(b => b.file)));
|
|
335
|
+
const testFilesHash = computeContentAggregateHash(targetDir, boundTestFiles);
|
|
336
|
+
const implementationFiles = Array.from(new Set(tasks.flatMap(t => t.files || [])))
|
|
337
|
+
.filter(f => !f.endsWith('closure.json'));
|
|
338
|
+
const implementationContextHash = computeContentAggregateHash(targetDir, implementationFiles);
|
|
339
|
+
const requiredGateDefinitionHash = computeContentAggregateHash(targetDir, ['package.json']);
|
|
340
|
+
|
|
341
|
+
const freshContextObj = {
|
|
342
|
+
version: 1,
|
|
343
|
+
repository: repoContext,
|
|
344
|
+
phase_hashes: phaseHashes,
|
|
345
|
+
acceptance_signature: acceptanceSignature,
|
|
346
|
+
test_files_hash: testFilesHash,
|
|
347
|
+
implementation_context_hash: implementationContextHash,
|
|
348
|
+
required_gate_definition_hash: requiredGateDefinitionHash
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const freshContextHash = computeClosureContextHash(freshContextObj);
|
|
352
|
+
const recordedContextHash = manifest.closure_context ? manifest.closure_context.closure_context_hash : null;
|
|
353
|
+
|
|
354
|
+
if (recordedContextHash !== freshContextHash) {
|
|
355
|
+
logger.error(`[CLOSURE_EVIDENCE_STALE] La evidencia de cierre está desactualizada respecto al estado actual del proyecto. Re-ejecuta "gemstack collect". (Registrado: ${recordedContextHash ? recordedContextHash.slice(0, 12) : 'none'}..., Actual: ${freshContextHash.slice(0, 12)}...)`);
|
|
356
|
+
totalErrors++;
|
|
357
|
+
} else {
|
|
358
|
+
logger.ok(`Frescura de evidencia de cierre verificada (${freshContextHash.slice(0, 12)}...).`);
|
|
359
|
+
|
|
360
|
+
if (manifest.status !== 'VERIFIED' && manifest.status !== 'VERIFIED_WITH_EXCEPTIONS') {
|
|
361
|
+
logger.error(`[CLOSURE_NOT_VERIFIED] Estado del manifiesto es "${manifest.status}". Bloqueadores: ${JSON.stringify(manifest.blockers || [])}`);
|
|
362
|
+
totalErrors++;
|
|
363
|
+
} else {
|
|
364
|
+
logger.ok(`Evidencia de cierre aprobada: status="${manifest.status}", ${manifest.canonical_summary ? manifest.canonical_summary.required_passed : 0}/${manifest.canonical_summary ? manifest.canonical_summary.required_total : 0} pruebas canónicas pasadas.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} catch (mErr) {
|
|
372
|
+
logger.error(`Error en verificación de evidencia de cierre: ${mErr.message}`);
|
|
373
|
+
totalErrors++;
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
logger.ok('Sin spec activa configurada para validación de evidencia de cierre.');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 6. Seguridad Local y Anti-Silent Failures en Tests
|
|
380
|
+
logger.info('--- 6/6 Verificación de Seguridad y Test Runners ---');
|
|
259
381
|
const envPath = fssafe.resolveSafe(targetDir, '.env');
|
|
260
382
|
if (fs.existsSync(envPath)) {
|
|
261
383
|
logger.warn('Archivo .env detectado en el directorio de trabajo. Verifica que esté en .gitignore.');
|