gemstack-ai 1.0.1 → 1.1.2
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/rules/01-gemstack-core.md +15 -0
- package/.agents/rules/02-gemstack-constitution.md +12 -1
- package/.agents/skills/gemstack-handoff/SKILL.md +2 -1
- package/.agents/skills/gemstack-plan/SKILL.md +2 -1
- package/.agents/skills/gemstack-review/SKILL.md +7 -5
- package/.agents/skills/gemstack-ship/SKILL.md +5 -0
- package/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/.gemstack/state.json +20 -2
- package/CHANGELOG.md +52 -0
- package/MANUAL.md +4 -4
- package/README.md +36 -8
- package/RELEASE_NOTES.md +105 -0
- package/assets/logo.jpg +0 -0
- package/docs/architecture-consistency.md +144 -0
- package/gemstack-ai-1.1.2.tgz +0 -0
- package/handoff.md +27 -40
- package/package.json +3 -2
- package/scripts/ci/smoke-cli.js +1 -0
- package/specs/006-architecture-consistency-engine/.gemstack.json +9 -0
- package/specs/006-architecture-consistency-engine/plan.md +319 -0
- package/specs/006-architecture-consistency-engine/spec.md +179 -0
- package/specs/006-architecture-consistency-engine/tasks.md +532 -0
- package/specs/templates/plan.md +11 -0
- package/specs/templates/spec.md +17 -0
- package/specs/templates/tasks.md +1 -0
- package/src/cli.js +9 -4
- package/src/commands/verify.js +311 -0
- package/src/lib/contracts.js +388 -0
- package/src/lib/findings.js +227 -0
- package/src/lib/hasher.js +103 -0
- package/src/lib/state.js +143 -0
- package/src/mcp-server.js +1 -1
- package/template/.agents/rules/01-gemstack-core.md +15 -0
- package/template/.agents/rules/02-gemstack-constitution.md +12 -1
- package/template/.agents/skills/gemstack-handoff/SKILL.md +2 -1
- package/template/.agents/skills/gemstack-plan/SKILL.md +2 -1
- package/template/.agents/skills/gemstack-review/SKILL.md +7 -5
- package/template/.agents/skills/gemstack-ship/SKILL.md +5 -0
- package/template/.agents/skills/gemstack-spec/SKILL.md +3 -2
- package/template/.agents/skills/gemstack-tasks/SKILL.md +4 -3
- package/template/docs/architecture-consistency.md +144 -0
- package/template/specs/templates/plan.md +11 -0
- package/template/specs/templates/spec.md +17 -0
- package/template/specs/templates/tasks.md +1 -0
- package/gemstack-ai-1.0.1.tgz +0 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const fssafe = require('../lib/filesystem-safe');
|
|
5
|
+
const logger = require('../lib/logger');
|
|
6
|
+
const manifestLib = require('../lib/manifest');
|
|
7
|
+
|
|
8
|
+
module.exports = async (flags) => {
|
|
9
|
+
const targetDir = flags.target || process.cwd();
|
|
10
|
+
logger.info(`Ejecutando verificación integral de Gemstack en: ${targetDir}`);
|
|
11
|
+
|
|
12
|
+
let totalErrors = 0;
|
|
13
|
+
let totalWarnings = 0;
|
|
14
|
+
|
|
15
|
+
// 1. Verificación Estructural y Manifest
|
|
16
|
+
logger.info('--- 1/5 Verificación Estructural (Archivos Base) ---');
|
|
17
|
+
const manifestPath = fssafe.resolveSafe(targetDir, '.gemstack/manifest.json');
|
|
18
|
+
if (!fs.existsSync(manifestPath)) {
|
|
19
|
+
logger.warn('Manifest no encontrado (.gemstack/manifest.json). Es posible que Gemstack no esté inicializado en este directorio.');
|
|
20
|
+
totalWarnings++;
|
|
21
|
+
} else {
|
|
22
|
+
try {
|
|
23
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
24
|
+
let missing = 0;
|
|
25
|
+
let modified = 0;
|
|
26
|
+
(manifest.files || []).forEach(f => {
|
|
27
|
+
const p = fssafe.resolveSafe(targetDir, f.path);
|
|
28
|
+
if (!fs.existsSync(p)) {
|
|
29
|
+
logger.error(`Archivo requerido faltante: ${f.path}`);
|
|
30
|
+
missing++;
|
|
31
|
+
} else {
|
|
32
|
+
const destContent = fs.readFileSync(p);
|
|
33
|
+
if (manifestLib.getChecksum(destContent) !== f.checksum) {
|
|
34
|
+
modified++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (missing === 0 && modified === 0) {
|
|
39
|
+
logger.ok('Todos los archivos base de Gemstack están presentes e íntegros.');
|
|
40
|
+
} else {
|
|
41
|
+
if (missing > 0) totalErrors += missing;
|
|
42
|
+
if (modified > 0) {
|
|
43
|
+
logger.info(`${modified} archivos base modificados localmente (personalización permitida).`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
logger.error(`Error leyendo manifest.json: ${e.message}`);
|
|
48
|
+
totalErrors++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const giPath = fssafe.resolveSafe(targetDir, '.gitignore');
|
|
53
|
+
if (fs.existsSync(giPath) && fs.readFileSync(giPath, 'utf8').includes('# Gemstack')) {
|
|
54
|
+
logger.ok('.gitignore contiene el bloque de Gemstack.');
|
|
55
|
+
} else {
|
|
56
|
+
logger.warn('.gitignore no está configurado con las exclusiones de Gemstack.');
|
|
57
|
+
totalWarnings++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2. Verificación de Memoria (handoff.md)
|
|
61
|
+
logger.info('--- 2/5 Verificación de Memoria e Integridad de Handoff ---');
|
|
62
|
+
const handoffPath = fssafe.resolveSafe(targetDir, 'handoff.md');
|
|
63
|
+
if (!fs.existsSync(handoffPath)) {
|
|
64
|
+
logger.error('handoff.md no existe en la raíz. La memoria de sesión es obligatoria.');
|
|
65
|
+
totalErrors++;
|
|
66
|
+
} else {
|
|
67
|
+
const handoffContent = fs.readFileSync(handoffPath, 'utf8');
|
|
68
|
+
const requiredSections = [
|
|
69
|
+
'1. Objetivo',
|
|
70
|
+
'2. Estado actual',
|
|
71
|
+
'3. Archivos y cambios',
|
|
72
|
+
'4. Intentos fallidos',
|
|
73
|
+
'5. Próximos pasos'
|
|
74
|
+
];
|
|
75
|
+
const missingSections = requiredSections.filter(s => !handoffContent.includes(s));
|
|
76
|
+
if (missingSections.length > 0) {
|
|
77
|
+
logger.error(`handoff.md está incompleto. Faltan secciones obligatorias: ${missingSections.join(', ')}`);
|
|
78
|
+
totalErrors++;
|
|
79
|
+
} else {
|
|
80
|
+
logger.ok('handoff.md contiene las 5 secciones obligatorias.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Regla inmutable: '4. Intentos fallidos' no debe estar eliminada
|
|
84
|
+
if (!handoffContent.includes('4. Intentos fallidos')) {
|
|
85
|
+
logger.error('Violación de la Constitución: La sección "4. Intentos fallidos" fue eliminada.');
|
|
86
|
+
totalErrors++;
|
|
87
|
+
} else {
|
|
88
|
+
logger.ok('Sección inmutable "4. Intentos fallidos" preservada.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Consistencia de Estado Local (.gemstack/state.json)
|
|
93
|
+
logger.info('--- 3/5 Verificación de Estado Local (.gemstack/state.json) ---');
|
|
94
|
+
const statePath = fssafe.resolveSafe(targetDir, '.gemstack/state.json');
|
|
95
|
+
let loadedState = null;
|
|
96
|
+
if (!fs.existsSync(statePath)) {
|
|
97
|
+
logger.warn('.gemstack/state.json no encontrado.');
|
|
98
|
+
totalWarnings++;
|
|
99
|
+
} else {
|
|
100
|
+
try {
|
|
101
|
+
const { readState } = require('../lib/state');
|
|
102
|
+
loadedState = readState(targetDir);
|
|
103
|
+
logger.ok(`Estado local cargado. Fase actual: ${loadedState.current_phase || 'no definida'}`);
|
|
104
|
+
if (loadedState.active_spec) {
|
|
105
|
+
const specFile = fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'spec.md'));
|
|
106
|
+
if (!fs.existsSync(specFile)) {
|
|
107
|
+
logger.warn(`Desfase de estado: active_spec apunta a "${loadedState.active_spec}", pero "${specFile}" no existe.`);
|
|
108
|
+
totalWarnings++;
|
|
109
|
+
} else {
|
|
110
|
+
logger.ok(`active_spec confirmado: ${loadedState.active_spec}`);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
logger.ok('Sin spec activa pendiente (estado limpio o cerrado).');
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
logger.error(`.gemstack/state.json tiene formato JSON inválido: ${e.message}`);
|
|
117
|
+
totalErrors++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 4. Consistencia de Arquitectura y Hashes de Fase (Upgrade A)
|
|
122
|
+
logger.info('--- 4/5 Verificación de Consistencia de Arquitectura y Hashes de Fase ---');
|
|
123
|
+
if (loadedState && loadedState.active_spec) {
|
|
124
|
+
try {
|
|
125
|
+
const { hashFile } = require('../lib/hasher');
|
|
126
|
+
const {
|
|
127
|
+
extractContractsBlock,
|
|
128
|
+
validateContractSchemas,
|
|
129
|
+
comparePhaseContracts,
|
|
130
|
+
resolvePhaseInheritance
|
|
131
|
+
} = require('../lib/contracts');
|
|
132
|
+
const {
|
|
133
|
+
reconcileFindings,
|
|
134
|
+
evaluateAcceptedExceptions,
|
|
135
|
+
formatDisplayFingerprint
|
|
136
|
+
} = require('../lib/findings');
|
|
137
|
+
|
|
138
|
+
const specFile = fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'spec.md'));
|
|
139
|
+
const planFile = fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'plan.md'));
|
|
140
|
+
const tasksFile = fssafe.resolveSafe(targetDir, path.join(loadedState.active_spec, 'tasks.md'));
|
|
141
|
+
|
|
142
|
+
if (fs.existsSync(specFile)) {
|
|
143
|
+
const specRaw = fs.readFileSync(specFile, 'utf8');
|
|
144
|
+
const specBlock = extractContractsBlock(specRaw);
|
|
145
|
+
|
|
146
|
+
if (specBlock.isLegacy) {
|
|
147
|
+
logger.ok(`[LEGACY] Feature "${loadedState.active_spec}" opera en modo legacy (sin bloques de contratos).`);
|
|
148
|
+
} else {
|
|
149
|
+
const specContracts = validateContractSchemas(specBlock.contracts);
|
|
150
|
+
logger.ok(`[STRUCTURED] ${specContracts.length} contrato(s) base declarados en spec.md.`);
|
|
151
|
+
|
|
152
|
+
// Detección de mutación de spec congelada (VERIFY != FREEZE)
|
|
153
|
+
if (loadedState.phase_hashes && loadedState.phase_hashes.spec) {
|
|
154
|
+
const currentSpecHash = hashFile(specFile);
|
|
155
|
+
if (currentSpecHash !== loadedState.phase_hashes.spec) {
|
|
156
|
+
logger.error(`[FROZEN_ARTIFACT_CHANGED] El artefacto congelado spec.md fue mutado sin autorización. Hash esperado: ${loadedState.phase_hashes.spec}, actual: ${currentSpecHash}`);
|
|
157
|
+
totalErrors++;
|
|
158
|
+
} else {
|
|
159
|
+
logger.ok(`Hash congelado de spec.md verificado: ${loadedState.phase_hashes.spec.slice(0, 12)}...`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Validación de PLAN si existe
|
|
164
|
+
let effectiveUpstream = specContracts;
|
|
165
|
+
let planContracts = [];
|
|
166
|
+
let violations = [];
|
|
167
|
+
|
|
168
|
+
if (fs.existsSync(planFile)) {
|
|
169
|
+
const planRaw = fs.readFileSync(planFile, 'utf8');
|
|
170
|
+
const planBlock = extractContractsBlock(planRaw);
|
|
171
|
+
if (!planBlock.isLegacy) {
|
|
172
|
+
planContracts = validateContractSchemas(planBlock.contracts);
|
|
173
|
+
const planViolations = comparePhaseContracts(effectiveUpstream, planContracts, 'plan');
|
|
174
|
+
violations.push(...planViolations.map(v => ({ ...v, location: path.join(loadedState.active_spec, 'plan.md') })));
|
|
175
|
+
effectiveUpstream = resolvePhaseInheritance(effectiveUpstream, planContracts);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (loadedState.phase_hashes && loadedState.phase_hashes.plan) {
|
|
179
|
+
const currentPlanHash = hashFile(planFile);
|
|
180
|
+
if (currentPlanHash !== loadedState.phase_hashes.plan) {
|
|
181
|
+
logger.error(`[FROZEN_ARTIFACT_CHANGED] El artefacto congelado plan.md fue mutado sin autorización. Hash esperado: ${loadedState.phase_hashes.plan}, actual: ${currentPlanHash}`);
|
|
182
|
+
totalErrors++;
|
|
183
|
+
} else {
|
|
184
|
+
logger.ok(`Hash congelado de plan.md verificado: ${loadedState.phase_hashes.plan.slice(0, 12)}...`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Validación de TASKS si existe
|
|
190
|
+
if (fs.existsSync(tasksFile)) {
|
|
191
|
+
const tasksRaw = fs.readFileSync(tasksFile, 'utf8');
|
|
192
|
+
const tasksBlock = extractContractsBlock(tasksRaw);
|
|
193
|
+
if (!tasksBlock.isLegacy) {
|
|
194
|
+
const tasksContracts = validateContractSchemas(tasksBlock.contracts);
|
|
195
|
+
const tasksViolations = comparePhaseContracts(effectiveUpstream, tasksContracts, 'tasks');
|
|
196
|
+
violations.push(...tasksViolations.map(v => ({ ...v, location: path.join(loadedState.active_spec, 'tasks.md') })));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (loadedState.phase_hashes && loadedState.phase_hashes.tasks) {
|
|
200
|
+
const currentTasksHash = hashFile(tasksFile);
|
|
201
|
+
if (currentTasksHash !== loadedState.phase_hashes.tasks) {
|
|
202
|
+
logger.error(`[FROZEN_ARTIFACT_CHANGED] El artefacto congelado tasks.md fue mutado sin autorización. Hash esperado: ${loadedState.phase_hashes.tasks}, actual: ${currentTasksHash}`);
|
|
203
|
+
totalErrors++;
|
|
204
|
+
} else {
|
|
205
|
+
logger.ok(`Hash congelado de tasks.md verificado: ${loadedState.phase_hashes.tasks.slice(0, 12)}...`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Reconciliación de hallazgos y evaluación de excepciones aceptadas vía sidecar de feature
|
|
211
|
+
const featureDir = fssafe.resolveSafe(targetDir, loadedState.active_spec);
|
|
212
|
+
const { readSidecar, writeSidecarAtomic } = require('../lib/state');
|
|
213
|
+
const sidecar = readSidecar(featureDir);
|
|
214
|
+
|
|
215
|
+
// Migración retrocompatible: si state tenía findings o accepted_exceptions, migrarlos al sidecar
|
|
216
|
+
const existingFindings = sidecar.historical_findings && sidecar.historical_findings.length > 0
|
|
217
|
+
? sidecar.historical_findings
|
|
218
|
+
: (loadedState.findings || []);
|
|
219
|
+
const acceptedExceptions = sidecar.accepted_exceptions && sidecar.accepted_exceptions.length > 0
|
|
220
|
+
? sidecar.accepted_exceptions
|
|
221
|
+
: (loadedState.accepted_exceptions || []);
|
|
222
|
+
|
|
223
|
+
const reconciled = reconcileFindings(existingFindings, violations);
|
|
224
|
+
const currentContext = {
|
|
225
|
+
upstreamAcceptedPhaseHash: (loadedState.phase_hashes && loadedState.phase_hashes.spec) || '',
|
|
226
|
+
currentComparedPhaseHash: (loadedState.phase_hashes && loadedState.phase_hashes.plan) || '',
|
|
227
|
+
normalizedContractRepresentation: JSON.stringify(effectiveUpstream)
|
|
228
|
+
};
|
|
229
|
+
const evaluated = evaluateAcceptedExceptions(reconciled, acceptedExceptions, currentContext);
|
|
230
|
+
|
|
231
|
+
// Persistir el historial detallado de hallazgos exclusivamente en el sidecar
|
|
232
|
+
sidecar.historical_findings = evaluated;
|
|
233
|
+
sidecar.accepted_exceptions = acceptedExceptions;
|
|
234
|
+
writeSidecarAtomic(featureDir, sidecar);
|
|
235
|
+
|
|
236
|
+
const blockers = evaluated.filter(f => f.is_blocking);
|
|
237
|
+
if (blockers.length > 0) {
|
|
238
|
+
for (const b of blockers) {
|
|
239
|
+
logger.error(`[CONSISTENCY_BLOCKER] Contrato "${b.contractId}" en fase "${b.phase}" (${formatDisplayFingerprint(b.fingerprint)}): ${JSON.stringify(b.delta)}`);
|
|
240
|
+
}
|
|
241
|
+
totalErrors += blockers.length;
|
|
242
|
+
} else {
|
|
243
|
+
logger.ok('Verificación de consistencia arquitectónica aprobada (0 bloqueadores).');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
logger.ok(`Modo legacy: no existe spec.md en ${loadedState.active_spec}`);
|
|
248
|
+
}
|
|
249
|
+
} catch (cErr) {
|
|
250
|
+
logger.error(`Error en verificación de consistencia: ${cErr.message}`);
|
|
251
|
+
totalErrors++;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
logger.ok('Sin spec activa configurada para verificación de contratos.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 5. Seguridad Local y Anti-Silent Failures en Tests
|
|
258
|
+
logger.info('--- 5/5 Verificación de Seguridad y Test Runners ---');
|
|
259
|
+
const envPath = fssafe.resolveSafe(targetDir, '.env');
|
|
260
|
+
if (fs.existsSync(envPath)) {
|
|
261
|
+
logger.warn('Archivo .env detectado en el directorio de trabajo. Verifica que esté en .gitignore.');
|
|
262
|
+
totalWarnings++;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const pkgPath = fssafe.resolveSafe(targetDir, 'package.json');
|
|
266
|
+
if (fs.existsSync(pkgPath)) {
|
|
267
|
+
try {
|
|
268
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
269
|
+
const scripts = pkg.scripts || {};
|
|
270
|
+
for (const [scriptName, scriptCmd] of Object.entries(scripts)) {
|
|
271
|
+
if (typeof scriptCmd === 'string') {
|
|
272
|
+
// Detección de supresores de error peligrosos en scripts
|
|
273
|
+
if (scriptCmd.includes('2>nul') || scriptCmd.includes('2> nul')) {
|
|
274
|
+
logger.error(`Falso positivo silencioso detectado en script "${scriptName}": contiene "2>nul", incompatible con PowerShell/Bash y enmascara fallos.`);
|
|
275
|
+
logger.info(`Solución: Estandariza el runner multiplataforma usando: "tsx --test <paths>" o "node --test <paths>"`);
|
|
276
|
+
totalErrors++;
|
|
277
|
+
} else if (scriptCmd.includes('|| true') && scriptName.includes('test')) {
|
|
278
|
+
logger.warn(`Script de prueba "${scriptName}" contiene "|| true", lo que ignora fallos de testing.`);
|
|
279
|
+
totalWarnings++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
logger.ok('Revisión de scripts de package.json completada.');
|
|
284
|
+
} catch (e) {
|
|
285
|
+
logger.warn(`No se pudo parsear package.json: ${e.message}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Ejecución de pruebas si se solicita explícitamente (--run-tests)
|
|
290
|
+
if (flags.runTests) {
|
|
291
|
+
logger.info('Ejecutando suite de pruebas (--run-tests activado)...');
|
|
292
|
+
try {
|
|
293
|
+
execSync('npm test', { cwd: targetDir, stdio: 'inherit' });
|
|
294
|
+
logger.ok('Suite de pruebas ejecutada con éxito.');
|
|
295
|
+
} catch (err) {
|
|
296
|
+
logger.error('La suite de pruebas falló.');
|
|
297
|
+
totalErrors++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Resumen Final
|
|
302
|
+
console.log('\n--- Resumen de Auditoría Gemstack ---');
|
|
303
|
+
if (totalErrors === 0 && totalWarnings === 0) {
|
|
304
|
+
logger.ok('Auditoría completada sin errores ni advertencias. Todo el sistema está saludable.');
|
|
305
|
+
} else if (totalErrors === 0) {
|
|
306
|
+
logger.ok(`Auditoría completada con éxito (${totalWarnings} advertencia(s) menores).`);
|
|
307
|
+
} else {
|
|
308
|
+
logger.error(`Auditoría finalizada con ${totalErrors} error(es) y ${totalWarnings} advertencia(s).`);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
const { normalizeContent } = require('./hasher');
|
|
2
|
+
|
|
3
|
+
const CANONICAL_CONTRACT_TYPES = [
|
|
4
|
+
'ENUM_SET',
|
|
5
|
+
'IDENTITY_TUPLE',
|
|
6
|
+
'PROVENANCE_RULE',
|
|
7
|
+
'BOOLEAN_INVARIANT',
|
|
8
|
+
'BOUNDARY',
|
|
9
|
+
'ROADMAP_LIMIT'
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extracts and parses the single canonical gemstack-contracts block from markdown.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} markdownContent
|
|
16
|
+
* @returns {{ contracts: Array<object>, isLegacy: boolean }}
|
|
17
|
+
*/
|
|
18
|
+
function extractContractsBlock(markdownContent) {
|
|
19
|
+
// Check BOM and normalize newlines via hasher
|
|
20
|
+
const normalized = normalizeContent(markdownContent);
|
|
21
|
+
|
|
22
|
+
const lines = normalized.split('\n');
|
|
23
|
+
const fence = '```';
|
|
24
|
+
const header = '```gemstack-contracts';
|
|
25
|
+
|
|
26
|
+
const blocks = [];
|
|
27
|
+
let inBlock = false;
|
|
28
|
+
let blockLines = [];
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (!inBlock) {
|
|
32
|
+
if (line.trimEnd() === header) {
|
|
33
|
+
inBlock = true;
|
|
34
|
+
blockLines = [];
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
if (line.trimEnd() === fence) {
|
|
38
|
+
inBlock = false;
|
|
39
|
+
blocks.push(blockLines.join('\n'));
|
|
40
|
+
} else {
|
|
41
|
+
blockLines.push(line);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (blocks.length === 0) {
|
|
47
|
+
return { contracts: [], isLegacy: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (blocks.length > 1) {
|
|
51
|
+
const err = new Error(`Multiple gemstack-contracts blocks detected (${blocks.length}). Exactly one is permitted.`);
|
|
52
|
+
err.code = 'CONTRACT_PARSE_ERROR';
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const jsonRaw = blocks[0].trim();
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(jsonRaw);
|
|
60
|
+
} catch (parseErr) {
|
|
61
|
+
const err = new Error(`Malformed JSON in gemstack-contracts block: ${parseErr.message}`);
|
|
62
|
+
err.code = 'CONTRACT_PARSE_ERROR';
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!Array.isArray(parsed)) {
|
|
67
|
+
const err = new Error('gemstack-contracts content must be a JSON array of contract objects');
|
|
68
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { contracts: parsed, isLegacy: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validates the schema of an array of contract objects and checks for duplicate IDs.
|
|
77
|
+
*
|
|
78
|
+
* @param {Array<object>} contractsArray
|
|
79
|
+
* @returns {Array<object>} Validated contracts
|
|
80
|
+
*/
|
|
81
|
+
function validateContractSchemas(contractsArray) {
|
|
82
|
+
if (!Array.isArray(contractsArray)) {
|
|
83
|
+
const err = new Error('Contracts must be an array');
|
|
84
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const seenIds = new Set();
|
|
89
|
+
|
|
90
|
+
for (const c of contractsArray) {
|
|
91
|
+
if (!c || typeof c !== 'object') {
|
|
92
|
+
const err = new Error('Each contract must be a non-null object');
|
|
93
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (typeof c.id !== 'string' || !c.id.trim()) {
|
|
98
|
+
const err = new Error('Contract missing valid string id');
|
|
99
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (seenIds.has(c.id)) {
|
|
104
|
+
const err = new Error(`Duplicate contract ID detected: "${c.id}"`);
|
|
105
|
+
err.code = 'CONTRACT_DUPLICATE_ID';
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
seenIds.add(c.id);
|
|
109
|
+
|
|
110
|
+
if (!CANONICAL_CONTRACT_TYPES.includes(c.type)) {
|
|
111
|
+
const err = new Error(`Unknown contract type: "${c.type}" on contract "${c.id}"`);
|
|
112
|
+
err.code = 'CONTRACT_UNKNOWN_TYPE';
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Type specific validations
|
|
117
|
+
if (c.type === 'ENUM_SET') {
|
|
118
|
+
if (!Array.isArray(c.values)) {
|
|
119
|
+
const err = new Error(`ENUM_SET "${c.id}" requires a values array`);
|
|
120
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
const seenVals = new Set();
|
|
124
|
+
for (const v of c.values) {
|
|
125
|
+
if (typeof v !== 'string' || !v.trim()) {
|
|
126
|
+
const err = new Error(`ENUM_SET "${c.id}" contains empty or non-string member`);
|
|
127
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
if (seenVals.has(v.trim())) {
|
|
131
|
+
const err = new Error(`ENUM_SET "${c.id}" contains duplicate value "${v}"`);
|
|
132
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
seenVals.add(v.trim());
|
|
136
|
+
}
|
|
137
|
+
} else if (c.type === 'IDENTITY_TUPLE') {
|
|
138
|
+
if (!Array.isArray(c.values)) {
|
|
139
|
+
const err = new Error(`IDENTITY_TUPLE "${c.id}" requires a values array of dimensions`);
|
|
140
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
const seenDims = new Set();
|
|
144
|
+
for (const d of c.values) {
|
|
145
|
+
if (typeof d !== 'string' || !d.trim()) {
|
|
146
|
+
const err = new Error(`IDENTITY_TUPLE "${c.id}" contains invalid dimension string`);
|
|
147
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
if (seenDims.has(d.trim())) {
|
|
151
|
+
const err = new Error(`IDENTITY_TUPLE "${c.id}" contains duplicate dimension "${d}"`);
|
|
152
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
seenDims.add(d.trim());
|
|
156
|
+
}
|
|
157
|
+
} else if (c.type === 'PROVENANCE_RULE') {
|
|
158
|
+
if (typeof c.entity !== 'string' || !c.entity.trim()) {
|
|
159
|
+
const err = new Error(`PROVENANCE_RULE "${c.id}" requires an entity string`);
|
|
160
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
if (!Array.isArray(c.values)) {
|
|
164
|
+
const err = new Error(`PROVENANCE_RULE "${c.id}" requires a values array of provenance fields`);
|
|
165
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
for (const f of c.values) {
|
|
169
|
+
if (typeof f !== 'string' || !f.trim()) {
|
|
170
|
+
const err = new Error(`PROVENANCE_RULE "${c.id}" contains invalid field string`);
|
|
171
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} else if (c.type === 'BOOLEAN_INVARIANT') {
|
|
176
|
+
if (typeof c.value !== 'boolean') {
|
|
177
|
+
const err = new Error(`BOOLEAN_INVARIANT "${c.id}" requires native boolean value, got ${typeof c.value}`);
|
|
178
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
} else if (c.type === 'BOUNDARY') {
|
|
182
|
+
if (c.value !== 'FORBIDDEN' && c.value !== 'REQUIRED') {
|
|
183
|
+
const err = new Error(`BOUNDARY "${c.id}" value must be strictly "FORBIDDEN" or "REQUIRED", got "${c.value}"`);
|
|
184
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
} else if (c.type === 'ROADMAP_LIMIT') {
|
|
188
|
+
if (typeof c.value !== 'number' && typeof c.value !== 'string') {
|
|
189
|
+
const err = new Error(`ROADMAP_LIMIT "${c.id}" value must be a number or string`);
|
|
190
|
+
err.code = 'CONTRACT_INVALID_SHAPE';
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return contractsArray;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Normalizes contract representation deterministically.
|
|
201
|
+
* Arrays of set-like members are trimmed and sorted alphabetically.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} contract
|
|
204
|
+
* @returns {object} Normalized copy
|
|
205
|
+
*/
|
|
206
|
+
function normalizeContract(contract) {
|
|
207
|
+
const norm = { ...contract };
|
|
208
|
+
if (norm.type === 'ENUM_SET' || norm.type === 'IDENTITY_TUPLE' || norm.type === 'PROVENANCE_RULE') {
|
|
209
|
+
if (Array.isArray(norm.values)) {
|
|
210
|
+
norm.values = norm.values.map(v => typeof v === 'string' ? v.trim() : v).slice().sort();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return norm;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Resolves cumulative phase inheritance.
|
|
218
|
+
* Upstream contracts are inherited; downstream additions are merged if not contradicting.
|
|
219
|
+
*
|
|
220
|
+
* @param {Array<object>} upstreamContracts
|
|
221
|
+
* @param {Array<object>} currentContracts
|
|
222
|
+
* @returns {Array<object>} Consolidated effective contract registry
|
|
223
|
+
*/
|
|
224
|
+
function resolvePhaseInheritance(upstreamContracts = [], currentContracts = []) {
|
|
225
|
+
const effective = new Map();
|
|
226
|
+
|
|
227
|
+
for (const c of upstreamContracts) {
|
|
228
|
+
effective.set(c.id, normalizeContract(c));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const c of currentContracts) {
|
|
232
|
+
// If it's an additive contract not present in upstream
|
|
233
|
+
if (!effective.has(c.id)) {
|
|
234
|
+
effective.set(c.id, normalizeContract(c));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return Array.from(effective.values());
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Compares current phase contracts against upstream accepted contracts.
|
|
243
|
+
* Emits one consolidated violation finding per (contractId, phase, violationType).
|
|
244
|
+
*
|
|
245
|
+
* @param {Array<object>} upstreamContracts
|
|
246
|
+
* @param {Array<object>} currentContracts
|
|
247
|
+
* @param {string} currentPhase - e.g. 'plan' or 'tasks'
|
|
248
|
+
* @returns {Array<object>} List of violations
|
|
249
|
+
*/
|
|
250
|
+
function comparePhaseContracts(upstreamContracts = [], currentContracts = [], currentPhase = 'plan') {
|
|
251
|
+
const violations = [];
|
|
252
|
+
const currentMap = new Map();
|
|
253
|
+
for (const c of currentContracts) {
|
|
254
|
+
currentMap.set(c.id, normalizeContract(c));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const up of upstreamContracts) {
|
|
258
|
+
const normUp = normalizeContract(up);
|
|
259
|
+
// If downstream does not redeclare an inherited contract, that is PASS (implicit inheritance)
|
|
260
|
+
if (!currentMap.has(normUp.id)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const normCurr = currentMap.get(normUp.id);
|
|
265
|
+
|
|
266
|
+
// If contract types differ
|
|
267
|
+
if (normCurr.type !== normUp.type) {
|
|
268
|
+
violations.push({
|
|
269
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
270
|
+
contractId: normUp.id,
|
|
271
|
+
phase: currentPhase,
|
|
272
|
+
violationType: 'TYPE_MUTATION',
|
|
273
|
+
delta: {
|
|
274
|
+
expectedType: normUp.type,
|
|
275
|
+
observedType: normCurr.type
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Comparison by type
|
|
282
|
+
if (normUp.type === 'ENUM_SET') {
|
|
283
|
+
const upSet = new Set(normUp.values);
|
|
284
|
+
const currSet = new Set(normCurr.values);
|
|
285
|
+
|
|
286
|
+
const added = normCurr.values.filter(v => !upSet.has(v)).sort();
|
|
287
|
+
const missing = normUp.values.filter(v => !currSet.has(v)).sort();
|
|
288
|
+
|
|
289
|
+
if (added.length > 0 || missing.length > 0) {
|
|
290
|
+
violations.push({
|
|
291
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
292
|
+
contractId: normUp.id,
|
|
293
|
+
phase: currentPhase,
|
|
294
|
+
violationType: 'ENUM_SET_MISMATCH',
|
|
295
|
+
delta: { added, missing }
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
} else if (normUp.type === 'IDENTITY_TUPLE') {
|
|
299
|
+
const upSet = new Set(normUp.values);
|
|
300
|
+
const currSet = new Set(normCurr.values);
|
|
301
|
+
|
|
302
|
+
const added = normCurr.values.filter(d => !upSet.has(d)).sort();
|
|
303
|
+
const missing = normUp.values.filter(d => !currSet.has(d)).sort();
|
|
304
|
+
|
|
305
|
+
if (added.length > 0 || missing.length > 0 || normUp.values.length !== normCurr.values.length) {
|
|
306
|
+
violations.push({
|
|
307
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
308
|
+
contractId: normUp.id,
|
|
309
|
+
phase: currentPhase,
|
|
310
|
+
violationType: 'IDENTITY_TUPLE_MISMATCH',
|
|
311
|
+
delta: { added, missing }
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
} else if (normUp.type === 'PROVENANCE_RULE') {
|
|
315
|
+
let entityMismatch = normUp.entity !== normCurr.entity;
|
|
316
|
+
const upSet = new Set(normUp.values);
|
|
317
|
+
const currSet = new Set(normCurr.values);
|
|
318
|
+
|
|
319
|
+
const added = normCurr.values.filter(f => !upSet.has(f)).sort();
|
|
320
|
+
const missing = normUp.values.filter(f => !currSet.has(f)).sort();
|
|
321
|
+
|
|
322
|
+
if (entityMismatch || added.length > 0 || missing.length > 0) {
|
|
323
|
+
violations.push({
|
|
324
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
325
|
+
contractId: normUp.id,
|
|
326
|
+
phase: currentPhase,
|
|
327
|
+
violationType: 'PROVENANCE_MISMATCH',
|
|
328
|
+
delta: {
|
|
329
|
+
entityExpected: normUp.entity,
|
|
330
|
+
entityObserved: normCurr.entity,
|
|
331
|
+
added,
|
|
332
|
+
missing
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} else if (normUp.type === 'BOOLEAN_INVARIANT') {
|
|
337
|
+
if (normUp.value !== normCurr.value) {
|
|
338
|
+
violations.push({
|
|
339
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
340
|
+
contractId: normUp.id,
|
|
341
|
+
phase: currentPhase,
|
|
342
|
+
violationType: 'BOOLEAN_CONTRADICTION',
|
|
343
|
+
delta: {
|
|
344
|
+
expected: normUp.value,
|
|
345
|
+
observed: normCurr.value
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
} else if (normUp.type === 'BOUNDARY') {
|
|
350
|
+
if (normUp.value !== normCurr.value) {
|
|
351
|
+
violations.push({
|
|
352
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
353
|
+
contractId: normUp.id,
|
|
354
|
+
phase: currentPhase,
|
|
355
|
+
violationType: 'BOUNDARY_CONTRADICTION',
|
|
356
|
+
delta: {
|
|
357
|
+
expected: normUp.value,
|
|
358
|
+
observed: normCurr.value
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
} else if (normUp.type === 'ROADMAP_LIMIT') {
|
|
363
|
+
if (normUp.value !== normCurr.value) {
|
|
364
|
+
violations.push({
|
|
365
|
+
code: 'FROZEN_CONTRACT_VIOLATION',
|
|
366
|
+
contractId: normUp.id,
|
|
367
|
+
phase: currentPhase,
|
|
368
|
+
violationType: 'ROADMAP_LIMIT_MISMATCH',
|
|
369
|
+
delta: {
|
|
370
|
+
expected: normUp.value,
|
|
371
|
+
observed: normCurr.value
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return violations;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
module.exports = {
|
|
382
|
+
CANONICAL_CONTRACT_TYPES,
|
|
383
|
+
extractContractsBlock,
|
|
384
|
+
validateContractSchemas,
|
|
385
|
+
normalizeContract,
|
|
386
|
+
resolvePhaseInheritance,
|
|
387
|
+
comparePhaseContracts
|
|
388
|
+
};
|