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.
Files changed (58) 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 +3 -1
  5. package/.agents/skills/gemstack-qa/SKILL.md +3 -0
  6. package/.agents/skills/gemstack-review/SKILL.md +7 -5
  7. package/.agents/skills/gemstack-ship/SKILL.md +10 -1
  8. package/.agents/skills/gemstack-spec/SKILL.md +4 -2
  9. package/.agents/skills/gemstack-tasks/SKILL.md +5 -3
  10. package/.gemstack/state.json +18 -2
  11. package/CHANGELOG.md +84 -0
  12. package/MANUAL.md +4 -4
  13. package/README.md +49 -8
  14. package/RELEASE_NOTES.md +129 -0
  15. package/assets/logo.jpg +0 -0
  16. package/docs/architecture-consistency.md +156 -0
  17. package/docs/spec-driven-development.md +26 -0
  18. package/gemstack-ai-1.2.0.tgz +0 -0
  19. package/handoff.md +40 -40
  20. package/package.json +3 -2
  21. package/scripts/ci/smoke-cli.js +1 -0
  22. package/specs/006-architecture-consistency-engine/.gemstack.json +9 -0
  23. package/specs/006-architecture-consistency-engine/plan.md +319 -0
  24. package/specs/006-architecture-consistency-engine/spec.md +179 -0
  25. package/specs/006-architecture-consistency-engine/tasks.md +532 -0
  26. package/specs/007-mechanical-test-matrix-closure-evidence/.gemstack.json +5 -0
  27. package/specs/007-mechanical-test-matrix-closure-evidence/closure.json +59 -0
  28. package/specs/007-mechanical-test-matrix-closure-evidence/plan.md +484 -0
  29. package/specs/007-mechanical-test-matrix-closure-evidence/spec.md +597 -0
  30. package/specs/007-mechanical-test-matrix-closure-evidence/tasks.md +536 -0
  31. package/specs/templates/plan.md +41 -0
  32. package/specs/templates/spec.md +35 -0
  33. package/specs/templates/tasks.md +10 -0
  34. package/src/cli.js +15 -4
  35. package/src/commands/collect.js +340 -0
  36. package/src/commands/ship.js +79 -0
  37. package/src/commands/verify.js +433 -0
  38. package/src/lib/closure-context.js +444 -0
  39. package/src/lib/contracts.js +388 -0
  40. package/src/lib/findings.js +227 -0
  41. package/src/lib/hasher.js +103 -0
  42. package/src/lib/runner-adapters.js +347 -0
  43. package/src/lib/state.js +143 -0
  44. package/src/lib/test-matrix.js +187 -0
  45. package/src/mcp-server.js +1 -1
  46. package/template/.agents/rules/01-gemstack-core.md +15 -0
  47. package/template/.agents/rules/02-gemstack-constitution.md +12 -1
  48. package/template/.agents/skills/gemstack-handoff/SKILL.md +2 -1
  49. package/template/.agents/skills/gemstack-plan/SKILL.md +2 -1
  50. package/template/.agents/skills/gemstack-review/SKILL.md +7 -5
  51. package/template/.agents/skills/gemstack-ship/SKILL.md +5 -0
  52. package/template/.agents/skills/gemstack-spec/SKILL.md +3 -2
  53. package/template/.agents/skills/gemstack-tasks/SKILL.md +4 -3
  54. package/template/docs/architecture-consistency.md +144 -0
  55. package/template/specs/templates/plan.md +11 -0
  56. package/template/specs/templates/spec.md +17 -0
  57. package/template/specs/templates/tasks.md +1 -0
  58. 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
+ };