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.
Files changed (46) hide show
  1. package/.agents/rules/01-gemstack-core.md +15 -0
  2. package/.agents/rules/02-gemstack-constitution.md +12 -1
  3. package/.agents/skills/gemstack-handoff/SKILL.md +2 -1
  4. package/.agents/skills/gemstack-plan/SKILL.md +2 -1
  5. package/.agents/skills/gemstack-review/SKILL.md +7 -5
  6. package/.agents/skills/gemstack-ship/SKILL.md +5 -0
  7. package/.agents/skills/gemstack-spec/SKILL.md +3 -2
  8. package/.agents/skills/gemstack-tasks/SKILL.md +4 -3
  9. package/.gemstack/state.json +20 -2
  10. package/CHANGELOG.md +52 -0
  11. package/MANUAL.md +4 -4
  12. package/README.md +36 -8
  13. package/RELEASE_NOTES.md +105 -0
  14. package/assets/logo.jpg +0 -0
  15. package/docs/architecture-consistency.md +144 -0
  16. package/gemstack-ai-1.1.2.tgz +0 -0
  17. package/handoff.md +27 -40
  18. package/package.json +3 -2
  19. package/scripts/ci/smoke-cli.js +1 -0
  20. package/specs/006-architecture-consistency-engine/.gemstack.json +9 -0
  21. package/specs/006-architecture-consistency-engine/plan.md +319 -0
  22. package/specs/006-architecture-consistency-engine/spec.md +179 -0
  23. package/specs/006-architecture-consistency-engine/tasks.md +532 -0
  24. package/specs/templates/plan.md +11 -0
  25. package/specs/templates/spec.md +17 -0
  26. package/specs/templates/tasks.md +1 -0
  27. package/src/cli.js +9 -4
  28. package/src/commands/verify.js +311 -0
  29. package/src/lib/contracts.js +388 -0
  30. package/src/lib/findings.js +227 -0
  31. package/src/lib/hasher.js +103 -0
  32. package/src/lib/state.js +143 -0
  33. package/src/mcp-server.js +1 -1
  34. package/template/.agents/rules/01-gemstack-core.md +15 -0
  35. package/template/.agents/rules/02-gemstack-constitution.md +12 -1
  36. package/template/.agents/skills/gemstack-handoff/SKILL.md +2 -1
  37. package/template/.agents/skills/gemstack-plan/SKILL.md +2 -1
  38. package/template/.agents/skills/gemstack-review/SKILL.md +7 -5
  39. package/template/.agents/skills/gemstack-ship/SKILL.md +5 -0
  40. package/template/.agents/skills/gemstack-spec/SKILL.md +3 -2
  41. package/template/.agents/skills/gemstack-tasks/SKILL.md +4 -3
  42. package/template/docs/architecture-consistency.md +144 -0
  43. package/template/specs/templates/plan.md +11 -0
  44. package/template/specs/templates/spec.md +17 -0
  45. package/template/specs/templates/tasks.md +1 -0
  46. 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
+ };