gemstack-ai 1.0.1 → 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/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 +3 -1
- package/.agents/skills/gemstack-qa/SKILL.md +3 -0
- package/.agents/skills/gemstack-review/SKILL.md +7 -5
- package/.agents/skills/gemstack-ship/SKILL.md +10 -1
- package/.agents/skills/gemstack-spec/SKILL.md +4 -2
- package/.agents/skills/gemstack-tasks/SKILL.md +5 -3
- package/.gemstack/state.json +18 -2
- package/CHANGELOG.md +84 -0
- package/MANUAL.md +4 -4
- package/README.md +49 -8
- package/RELEASE_NOTES.md +129 -0
- package/assets/logo.jpg +0 -0
- package/docs/architecture-consistency.md +156 -0
- package/docs/spec-driven-development.md +26 -0
- package/gemstack-ai-1.2.0.tgz +0 -0
- package/handoff.md +40 -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/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 +41 -0
- package/specs/templates/spec.md +35 -0
- package/specs/templates/tasks.md +10 -0
- package/src/cli.js +15 -4
- package/src/commands/collect.js +340 -0
- package/src/commands/ship.js +79 -0
- package/src/commands/verify.js +433 -0
- package/src/lib/closure-context.js +444 -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/runner-adapters.js +347 -0
- package/src/lib/state.js +143 -0
- package/src/lib/test-matrix.js +187 -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,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
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const { normalizePath } = require('./hasher');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Computes canonical 64-character lowercase hexadecimal SHA-256 fingerprint for a finding.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} params
|
|
8
|
+
* @param {string} params.code - Finding error code
|
|
9
|
+
* @param {string|null} params.contractId - Optional contract id
|
|
10
|
+
* @param {string} params.phase - Phase where violation was found
|
|
11
|
+
* @param {string} params.location - File path of the violation
|
|
12
|
+
* @returns {string} 64-char lowercase hex SHA-256
|
|
13
|
+
*/
|
|
14
|
+
function computeFindingFingerprint({ code, contractId, phase, location }) {
|
|
15
|
+
const normLocation = normalizePath(location);
|
|
16
|
+
const payload = JSON.stringify({
|
|
17
|
+
code: code || 'UNKNOWN_ERROR',
|
|
18
|
+
contractId: contractId ?? null,
|
|
19
|
+
phase: phase || 'unknown',
|
|
20
|
+
location: normLocation || null
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Formats a cosmetic display string for a fingerprint (12-char prefix).
|
|
28
|
+
* Never used for internal storage, indexing or anti-loop logic.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} fingerprint
|
|
31
|
+
* @returns {string} 12-char display token
|
|
32
|
+
*/
|
|
33
|
+
function formatDisplayFingerprint(fingerprint) {
|
|
34
|
+
if (typeof fingerprint !== 'string') return '';
|
|
35
|
+
return fingerprint.slice(0, 12);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Creates a structured Finding object.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} params
|
|
42
|
+
* @returns {object} Finding
|
|
43
|
+
*/
|
|
44
|
+
function createFinding({ code, contractId, phase, location, delta = null, details = null }) {
|
|
45
|
+
const fp = computeFindingFingerprint({ code, contractId, phase, location });
|
|
46
|
+
return {
|
|
47
|
+
fingerprint: fp,
|
|
48
|
+
display_id: formatDisplayFingerprint(fp),
|
|
49
|
+
code,
|
|
50
|
+
contractId: contractId ?? null,
|
|
51
|
+
phase,
|
|
52
|
+
location: normalizePath(location),
|
|
53
|
+
delta,
|
|
54
|
+
details,
|
|
55
|
+
status: 'OPEN',
|
|
56
|
+
is_blocking: true,
|
|
57
|
+
detected_at: new Date().toISOString(),
|
|
58
|
+
resolved_at: null
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Computes the full frozen contextHash for an accepted exception.
|
|
64
|
+
* Context identity = SHA-256(upstreamAcceptedPhaseHash + currentComparedPhaseHash + normalizedContractRepresentation).
|
|
65
|
+
*
|
|
66
|
+
* @param {object} params
|
|
67
|
+
* @param {string} params.upstreamAcceptedPhaseHash
|
|
68
|
+
* @param {string} params.currentComparedPhaseHash
|
|
69
|
+
* @param {string} params.normalizedContractRepresentation - Deterministically serialized contract JSON
|
|
70
|
+
* @returns {string} 64-character lowercase hex SHA-256
|
|
71
|
+
*/
|
|
72
|
+
function computeContextHash({ upstreamAcceptedPhaseHash, currentComparedPhaseHash, normalizedContractRepresentation }) {
|
|
73
|
+
const payload = JSON.stringify({
|
|
74
|
+
upstreamAcceptedPhaseHash: upstreamAcceptedPhaseHash || '',
|
|
75
|
+
currentComparedPhaseHash: currentComparedPhaseHash || '',
|
|
76
|
+
normalizedContractRepresentation: normalizedContractRepresentation || ''
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return crypto.createHash('sha256').update(payload, 'utf8').digest('hex');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reconciles findings between successive checks (Anti-Loop mechanism):
|
|
84
|
+
* - If an existing finding is no longer in current violations -> status becomes 'RESOLVED'.
|
|
85
|
+
* - If a finding was 'RESOLVED' but the violation recurs -> status reopens to 'OPEN'.
|
|
86
|
+
* - New violations are added as 'OPEN'.
|
|
87
|
+
*
|
|
88
|
+
* @param {Array<object>} existingFindings
|
|
89
|
+
* @param {Array<object>} currentViolations
|
|
90
|
+
* @returns {Array<object>} Reconciled findings
|
|
91
|
+
*/
|
|
92
|
+
function reconcileFindings(existingFindings = [], currentViolations = []) {
|
|
93
|
+
const reconciled = [];
|
|
94
|
+
const currentViolationFps = new Map();
|
|
95
|
+
|
|
96
|
+
for (const v of currentViolations) {
|
|
97
|
+
const fp = computeFindingFingerprint({
|
|
98
|
+
code: v.code,
|
|
99
|
+
contractId: v.contractId,
|
|
100
|
+
phase: v.phase,
|
|
101
|
+
location: v.location
|
|
102
|
+
});
|
|
103
|
+
currentViolationFps.set(fp, v);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Process existing findings
|
|
107
|
+
for (const f of existingFindings) {
|
|
108
|
+
if (currentViolationFps.has(f.fingerprint)) {
|
|
109
|
+
const v = currentViolationFps.get(f.fingerprint);
|
|
110
|
+
currentViolationFps.delete(f.fingerprint); // Handled
|
|
111
|
+
|
|
112
|
+
// If it was RESOLVED, reopen it (anti-loop protection)
|
|
113
|
+
if (f.status === 'RESOLVED') {
|
|
114
|
+
reconciled.push({
|
|
115
|
+
...f,
|
|
116
|
+
status: 'OPEN',
|
|
117
|
+
is_blocking: true,
|
|
118
|
+
delta: v.delta,
|
|
119
|
+
detected_at: new Date().toISOString(),
|
|
120
|
+
resolved_at: null
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
reconciled.push({
|
|
124
|
+
...f,
|
|
125
|
+
delta: v.delta,
|
|
126
|
+
is_blocking: f.status !== 'ACCEPTED_EXCEPTION'
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// Violation not present in current analysis -> mark RESOLVED if it was OPEN
|
|
131
|
+
if (f.status === 'OPEN') {
|
|
132
|
+
reconciled.push({
|
|
133
|
+
...f,
|
|
134
|
+
status: 'RESOLVED',
|
|
135
|
+
is_blocking: false,
|
|
136
|
+
resolved_at: new Date().toISOString()
|
|
137
|
+
});
|
|
138
|
+
} else {
|
|
139
|
+
reconciled.push(f);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Any remaining current violations are new
|
|
145
|
+
for (const [fp, v] of currentViolationFps.entries()) {
|
|
146
|
+
reconciled.push(createFinding(v));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return reconciled;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Evaluates accepted exceptions against current findings.
|
|
154
|
+
* If an exception matches the finding fingerprint AND has an identical full contextHash,
|
|
155
|
+
* it suppresses the blocker (is_blocking: false, status: 'ACCEPTED_EXCEPTION').
|
|
156
|
+
* If contextHash differs, the exception does NOT suppress and finding remains OPEN / blocking.
|
|
157
|
+
*
|
|
158
|
+
* @param {Array<object>} findings
|
|
159
|
+
* @param {Array<object>} acceptedExceptions
|
|
160
|
+
* @param {object} currentContext - { upstreamAcceptedPhaseHash, currentComparedPhaseHash, normalizedContractRepresentation }
|
|
161
|
+
* @returns {Array<object>} Evaluated findings
|
|
162
|
+
*/
|
|
163
|
+
function evaluateAcceptedExceptions(findings = [], acceptedExceptions = [], currentContext = {}) {
|
|
164
|
+
const currentContextHash = computeContextHash(currentContext);
|
|
165
|
+
|
|
166
|
+
const exceptionMap = new Map();
|
|
167
|
+
for (const ex of acceptedExceptions) {
|
|
168
|
+
if (ex && ex.fingerprint) {
|
|
169
|
+
exceptionMap.set(ex.fingerprint, ex);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return findings.map(f => {
|
|
174
|
+
if (exceptionMap.has(f.fingerprint)) {
|
|
175
|
+
const ex = exceptionMap.get(f.fingerprint);
|
|
176
|
+
if (ex.contextHash === currentContextHash) {
|
|
177
|
+
return {
|
|
178
|
+
...f,
|
|
179
|
+
status: 'ACCEPTED_EXCEPTION',
|
|
180
|
+
is_blocking: false,
|
|
181
|
+
exceptionReason: ex.reason || 'Approved exception'
|
|
182
|
+
};
|
|
183
|
+
} else {
|
|
184
|
+
// Context mutated -> exception invalidated
|
|
185
|
+
return {
|
|
186
|
+
...f,
|
|
187
|
+
status: 'OPEN',
|
|
188
|
+
is_blocking: true,
|
|
189
|
+
exceptionInvalidated: true
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return f;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Marks findings as SUPERSEDED if associated contracts were formally amended or removed.
|
|
199
|
+
*
|
|
200
|
+
* @param {Array<object>} findings
|
|
201
|
+
* @param {Array<string>} activeContractIds
|
|
202
|
+
* @returns {Array<object>}
|
|
203
|
+
*/
|
|
204
|
+
function markSupersededFindings(findings = [], activeContractIds = []) {
|
|
205
|
+
const activeSet = new Set(activeContractIds);
|
|
206
|
+
return findings.map(f => {
|
|
207
|
+
if (f.contractId && !activeSet.has(f.contractId)) {
|
|
208
|
+
return {
|
|
209
|
+
...f,
|
|
210
|
+
status: 'SUPERSEDED',
|
|
211
|
+
is_blocking: false,
|
|
212
|
+
superseded_at: new Date().toISOString()
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return f;
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
computeFindingFingerprint,
|
|
221
|
+
formatDisplayFingerprint,
|
|
222
|
+
createFinding,
|
|
223
|
+
computeContextHash,
|
|
224
|
+
reconcileFindings,
|
|
225
|
+
evaluateAcceptedExceptions,
|
|
226
|
+
markSupersededFindings
|
|
227
|
+
};
|