@aikdna/kdna-studio-core 1.3.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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Studio Project lifecycle.
3
+ *
4
+ * Responsibilities:
5
+ * - Create, load, save, validate Studio Project manifests
6
+ * - Manage project-level state transitions
7
+ * - Schema validation against studio.project.schema.json
8
+ * - Human Lock gate enforcement on export
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const projectSchema = require('../../schemas/studio.project.schema.json');
13
+ const { JUDGMENT_CARD_TYPES, cardJudgmentFingerprint } = require('../judgment-fields');
14
+
15
+ function createProject(name, type = 'domain', options = {}) {
16
+ const project = {
17
+ studio_version: '0.1.0',
18
+ project_id: `studio_${require('crypto').randomUUID()}`,
19
+ name,
20
+ type,
21
+ created: new Date().toISOString().slice(0, 10),
22
+ updated: new Date().toISOString().slice(0, 10),
23
+ author: options.author || { name: '', id: '' },
24
+ status: 'drafting',
25
+ cards: [],
26
+ evidence: [],
27
+ tests: [],
28
+ stages: {
29
+ evidence_room: { status: 'pending', evidence_count: 0 },
30
+ interview_room: { status: 'pending', questions_asked: 0 },
31
+ judgment_cards: { status: 'pending', locked: 0, total: 0 },
32
+ test_lab: { status: 'pending', evals_passed: 0, evals_total: 0 },
33
+ export: { status: 'pending' },
34
+ },
35
+ };
36
+ return project;
37
+ }
38
+
39
+ function loadProject(json) {
40
+ let project;
41
+ try {
42
+ project = typeof json === 'string' ? JSON.parse(json) : json;
43
+ } catch (e) {
44
+ throw new Error('loadProject: invalid JSON input — ' + e.message);
45
+ }
46
+ if (!project || typeof project !== 'object' || Array.isArray(project)) {
47
+ throw new Error('loadProject: input is not a valid project object');
48
+ }
49
+ const result = validateProject(project);
50
+ if (!result.valid) {
51
+ throw new Error('loadProject: project validation failed:\n - ' + result.issues.join('\n - '));
52
+ }
53
+ return project;
54
+ }
55
+
56
+ function saveProject(project) {
57
+ project.updated = new Date().toISOString().slice(0, 10);
58
+ return JSON.stringify(project, null, 2);
59
+ }
60
+
61
+ function validateProject(project) {
62
+ const issues = [];
63
+
64
+ function check(cond, msg) { if (!cond) issues.push(msg); }
65
+
66
+ function checkType(val, expected, path) {
67
+ if (expected === 'string' && typeof val !== 'string') issues.push(path + ': expected string, got ' + typeof val);
68
+ else if (expected === 'number' && typeof val !== 'number') issues.push(path + ': expected number, got ' + typeof val);
69
+ else if (expected === 'integer' && (!Number.isInteger(val) || typeof val !== 'number')) issues.push(path + ': expected integer, got ' + typeof val);
70
+ else if (expected === 'boolean' && typeof val !== 'boolean') issues.push(path + ': expected boolean, got ' + typeof val);
71
+ else if (expected === 'object' && (typeof val !== 'object' || val === null || Array.isArray(val))) issues.push(path + ': expected object, got ' + (Array.isArray(val) ? 'array' : typeof val));
72
+ else if (expected === 'array' && !Array.isArray(val)) issues.push(path + ': expected array, got ' + typeof val);
73
+ }
74
+
75
+ // Required top-level fields
76
+ if (!project || typeof project !== 'object' || Array.isArray(project)) {
77
+ return { valid: false, issues: ['project must be a non-null object'] };
78
+ }
79
+
80
+ const required = projectSchema.required || [];
81
+ for (const field of required) {
82
+ if (!(field in project)) issues.push('Missing required field: ' + field);
83
+ }
84
+
85
+ // studio_version
86
+ if (project.studio_version !== undefined) {
87
+ checkType(project.studio_version, 'string', 'studio_version');
88
+ if (typeof project.studio_version === 'string') {
89
+ const verPattern = projectSchema.properties.studio_version.pattern;
90
+ if (verPattern && !new RegExp(verPattern).test(project.studio_version)) {
91
+ issues.push('studio_version: invalid version format, expected semver (e.g. 1.2.3)');
92
+ }
93
+ }
94
+ }
95
+
96
+ // name
97
+ if (project.name !== undefined) {
98
+ checkType(project.name, 'string', 'name');
99
+ if (typeof project.name === 'string' && project.name.length < 1) {
100
+ issues.push('name: must not be empty');
101
+ }
102
+ }
103
+
104
+ // type
105
+ if (project.type !== undefined) {
106
+ checkType(project.type, 'string', 'type');
107
+ if (typeof project.type === 'string' && !['domain', 'cluster'].includes(project.type)) {
108
+ issues.push('type: must be "domain" or "cluster", got "' + project.type + '"');
109
+ }
110
+ }
111
+
112
+ // status
113
+ if (project.status !== undefined) {
114
+ checkType(project.status, 'string', 'status');
115
+ if (typeof project.status === 'string') {
116
+ const validStatuses = ['drafting', 'cards_in_progress', 'ready_for_test', 'ready_for_release', 'released'];
117
+ if (!validStatuses.includes(project.status)) {
118
+ issues.push('status: must be one of ' + validStatuses.join(', ') + ', got "' + project.status + '"');
119
+ }
120
+ }
121
+ }
122
+
123
+ // project_id
124
+ if (project.project_id !== undefined) {
125
+ checkType(project.project_id, 'string', 'project_id');
126
+ }
127
+
128
+ // created / updated
129
+ if (project.created !== undefined) checkType(project.created, 'string', 'created');
130
+ if (project.updated !== undefined) checkType(project.updated, 'string', 'updated');
131
+
132
+ // author
133
+ if (project.author !== undefined) {
134
+ checkType(project.author, 'object', 'author');
135
+ }
136
+
137
+ // cards
138
+ if (project.cards !== undefined) {
139
+ checkType(project.cards, 'array', 'cards');
140
+ if (Array.isArray(project.cards)) {
141
+ for (let i = 0; i < project.cards.length; i++) {
142
+ const card = project.cards[i];
143
+ if (!card || typeof card !== 'object') {
144
+ issues.push('cards[' + i + ']: must be an object');
145
+ continue;
146
+ }
147
+ for (const req of ['id', 'type', 'status']) {
148
+ if (!(req in card)) issues.push('cards[' + i + ']: missing required field "' + req + '"');
149
+ }
150
+ if (card.type !== undefined) {
151
+ const validTypes = ['axiom', 'ontology', 'misunderstanding', 'boundary', 'self_check', 'risk', 'aesthetic', 'scenario', 'case'];
152
+ if (!validTypes.includes(card.type)) issues.push('cards[' + i + ']: invalid type "' + card.type + '"');
153
+ }
154
+ if (card.status !== undefined) {
155
+ const validStates = ['draft', 'revised', 'locked', 'tested', 'published', 'deprecated'];
156
+ if (!validStates.includes(card.status)) issues.push('cards[' + i + ']: invalid status "' + card.status + '"');
157
+ }
158
+ }
159
+ }
160
+ }
161
+
162
+ // evidence
163
+ if (project.evidence !== undefined) {
164
+ checkType(project.evidence, 'array', 'evidence');
165
+ if (Array.isArray(project.evidence)) {
166
+ for (let i = 0; i < project.evidence.length; i++) {
167
+ const ev = project.evidence[i];
168
+ if (!ev || typeof ev !== 'object') {
169
+ issues.push('evidence[' + i + ']: must be an object');
170
+ continue;
171
+ }
172
+ for (const req of ['id', 'type', 'title']) {
173
+ if (!(req in ev)) issues.push('evidence[' + i + ']: missing required field "' + req + '"');
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ // tests
180
+ if (project.tests !== undefined) {
181
+ checkType(project.tests, 'array', 'tests');
182
+ if (Array.isArray(project.tests)) {
183
+ for (let i = 0; i < project.tests.length; i++) {
184
+ const t = project.tests[i];
185
+ if (!t || typeof t !== 'object') {
186
+ issues.push('tests[' + i + ']: must be an object');
187
+ continue;
188
+ }
189
+ for (const req of ['id', 'input']) {
190
+ if (!(req in t)) issues.push('tests[' + i + ']: missing required field "' + req + '"');
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // stages
197
+ if (project.stages !== undefined) {
198
+ checkType(project.stages, 'object', 'stages');
199
+ }
200
+
201
+ return { valid: issues.length === 0, issues };
202
+ }
203
+
204
+ function upgradeProject(project, fromVersion, toVersion) {
205
+ const migrations = {
206
+ '0.1.0_to_0.2.0': function(p) {
207
+ if (!p.release) p.release = { version: toVersion };
208
+ return p;
209
+ }
210
+ };
211
+ const key = fromVersion + '_to_' + toVersion;
212
+ if (migrations[key]) {
213
+ project = migrations[key](project);
214
+ } else {
215
+ throw new Error('upgradeProject: no migration path from ' + fromVersion + ' to ' + toVersion);
216
+ }
217
+ project.studio_version = toVersion;
218
+ return project;
219
+ }
220
+
221
+ // ─── Human Lock Gate ────────────────────────────────────────────────────
222
+
223
+ /**
224
+ * Detect judgment-class cards that require Human Lock but don't have it,
225
+ * or have had their judgment fields changed since the last Human Lock.
226
+ *
227
+ * @returns {{ blocked: boolean, issues: Array<{cardId: string, type: string, reason: string}> }}
228
+ */
229
+ function checkHumanLockGate(project) {
230
+ const issues = [];
231
+ const cards = project.cards || [];
232
+
233
+ for (const card of cards) {
234
+ if (!JUDGMENT_CARD_TYPES.has(card.type)) continue;
235
+
236
+ const cardId = card.id || 'unknown';
237
+
238
+ // Rule 1: Judgment-class cards must be locked before export
239
+ if (card.status !== 'locked' && card.status !== 'tested' && card.status !== 'published') {
240
+ issues.push({
241
+ cardId,
242
+ type: card.type,
243
+ reason: `judgment-class card "${cardId}" (${card.type}) is not locked. Human Lock required before export.`
244
+ });
245
+ continue;
246
+ }
247
+
248
+ // Rule 2: Locked cards must have a Human Lock record
249
+ if (!card.human_lock || !card.human_lock.by || !card.human_lock.statement) {
250
+ issues.push({
251
+ cardId,
252
+ type: card.type,
253
+ reason: `locked card "${cardId}" (${card.type}) has no valid Human Lock record (missing by/statement).`
254
+ });
255
+ continue;
256
+ }
257
+
258
+ // Rule 3: Lock must confirm judgment-class fields were reviewed
259
+ const checked = card.human_lock.checked || {};
260
+ if (!checked.applies_when) {
261
+ issues.push({
262
+ cardId,
263
+ type: card.type,
264
+ reason: `card "${cardId}" Human Lock does not confirm applies_when was reviewed.`
265
+ });
266
+ }
267
+ if (!checked.does_not_apply_when) {
268
+ issues.push({
269
+ cardId,
270
+ type: card.type,
271
+ reason: `card "${cardId}" Human Lock does not confirm does_not_apply_when was reviewed.`
272
+ });
273
+ }
274
+ if (!checked.failure_risk) {
275
+ issues.push({
276
+ cardId,
277
+ type: card.type,
278
+ reason: `card "${cardId}" Human Lock does not confirm failure_risk was reviewed.`
279
+ });
280
+ }
281
+
282
+ // Rule 4: Judgment fields must not have changed since lock
283
+ if (card.human_lock.judgment_fingerprint) {
284
+ const currentFingerprint = cardJudgmentFingerprint(card);
285
+ if (currentFingerprint !== card.human_lock.judgment_fingerprint) {
286
+ issues.push({
287
+ cardId,
288
+ type: card.type,
289
+ reason: `card "${cardId}" judgment fields changed after Human Lock — re-lock required.`
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ // Rule 4: At least one locked judgment-class card must exist for a domain project
296
+ const lockedJudgmentCards = cards.filter(c =>
297
+ JUDGMENT_CARD_TYPES.has(c.type) &&
298
+ ['locked', 'tested', 'published'].includes(c.status)
299
+ );
300
+ if (lockedJudgmentCards.length === 0 && cards.some(c => JUDGMENT_CARD_TYPES.has(c.type))) {
301
+ issues.push({
302
+ cardId: '(project)',
303
+ type: 'project',
304
+ reason: 'No judgment-class cards are locked. At least one axiom, boundary, or risk card must be Human Locked before export.'
305
+ });
306
+ }
307
+
308
+ return {
309
+ blocked: issues.length > 0,
310
+ issues,
311
+ lockedJudgmentCards: lockedJudgmentCards.length,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Export a project for release, with Human Lock gate enforcement.
317
+ * Blocks if any judgment-class cards are not properly locked.
318
+ *
319
+ * @throws {Error} if Human Lock gate blocks export
320
+ * @returns {string} JSON string of the project
321
+ */
322
+ function exportProject(project, options = {}) {
323
+ const gate = checkHumanLockGate(project);
324
+
325
+ if (gate.blocked && !options.force) {
326
+ const lines = ['Human Lock Gate blocked export:', ''];
327
+ for (const issue of gate.issues) {
328
+ lines.push(` ✗ ${issue.cardId}: ${issue.reason}`);
329
+ }
330
+ lines.push('');
331
+ lines.push(` Locked judgment cards: ${gate.lockedJudgmentCards}`);
332
+ lines.push(' To override (emergency only): pass { force: true }');
333
+ throw new Error(lines.join('\n'));
334
+ }
335
+
336
+ if (gate.blocked && options.force) {
337
+ // Emergency override: recorded but not blocked
338
+ project._human_lock_override = {
339
+ overridden_at: new Date().toISOString(),
340
+ reason: options.forceReason || 'Emergency override (no reason provided)',
341
+ blocked_issues: gate.issues.map(i => ({ cardId: i.cardId, reason: i.reason })),
342
+ };
343
+ }
344
+
345
+ project.updated = new Date().toISOString().slice(0, 10);
346
+
347
+ // Update release metadata
348
+ if (!project.release) project.release = {};
349
+ project.release.exported_at = new Date().toISOString();
350
+ project.release.locked_judgment_cards = gate.lockedJudgmentCards;
351
+ project.release.human_lock_gate_passed = !gate.blocked || !!options.force;
352
+
353
+ return JSON.stringify(project, null, 2);
354
+ }
355
+
356
+ module.exports = {
357
+ createProject, loadProject, saveProject, validateProject, upgradeProject,
358
+ exportProject, checkHumanLockGate, JUDGMENT_CARD_TYPES
359
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Provenance tracking — build metadata and content fingerprinting.
3
+ *
4
+ * Every compiled KDNA domain carries provenance proving:
5
+ * - Which Studio Core version created it
6
+ * - Which project it came from
7
+ * - Who authored the locked cards
8
+ * - Content tree fingerprint
9
+ */
10
+ const crypto = require('crypto');
11
+
12
+ function buildProvenance(project, compiledFiles, identity = {}) {
13
+ const lockedCards = (project.cards || []).filter(c => c.locked);
14
+ const tests = project.tests || [];
15
+
16
+ // Content fingerprint: hash of all locked card content
17
+ const cardHashes = lockedCards
18
+ .sort((a, b) => a.id.localeCompare(b.id))
19
+ .map(c => `${c.id}:${c.fields?.one_sentence || c.fields?.concept || ''}`);
20
+ const contentFingerprint = crypto.createHash('sha256').update(cardHashes.join('\n')).digest('hex');
21
+
22
+ return {
23
+ studio_core: 'aikdna/kdna-studio-core',
24
+ studio_core_version: project.studio_version || require('../../package.json').version,
25
+ created_by: 'kdna-studio-sdk',
26
+ compiler: '@aikdna/kdna-studio-core',
27
+ compiler_version: project.studio_version || require('../../package.json').version,
28
+ build_id: identity.build_id || `build_${crypto.randomUUID()}`,
29
+ project_id: project.project_id,
30
+ project_uid: identity.project_uid || project.project_uid || project.project_id || null,
31
+ asset_uid: identity.asset_uid || null,
32
+ domain_id: identity.domain_id || null,
33
+ registry_name: identity.registry_name || project.name || null,
34
+ author_id: project.author?.id || '',
35
+ locked_card_count: lockedCards.length,
36
+ test_case_count: tests.length,
37
+ built_at: identity.compiled_at || new Date().toISOString(),
38
+ compiled_at: identity.compiled_at || new Date().toISOString(),
39
+ content_fingerprint: identity.content_digest || `sha256:${contentFingerprint}`,
40
+ content_digest: identity.content_digest || `sha256:${contentFingerprint}`,
41
+ };
42
+ }
43
+
44
+ module.exports = { buildProvenance };
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Contradiction Check — Surface conflicts, gaps, and weak judgment.
3
+ *
4
+ * Detects:
5
+ * - Axiom contradictions (two axioms that cannot both be true)
6
+ * - Missing boundaries (axiom lacks does_not_apply_when)
7
+ * - Weak self-checks (not a yes/no question, too vague)
8
+ * - Over-generalized axioms (too broad to be testable)
9
+ * - Straw-man misunderstandings (describes something no one believes)
10
+ * - Missing counterexamples (misunderstanding lacks a real example)
11
+ */
12
+
13
+ function tokenize(text) {
14
+ if (!text) return [];
15
+ try {
16
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
17
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
18
+ return [...segmenter.segment(text)].filter(s => s.isWordLike && s.segment.length > 3).map(s => s.segment);
19
+ }
20
+ } catch { /* fallback */ }
21
+ return text.split(/\s+/).filter(w => w.length > 3);
22
+ }
23
+
24
+ function detectContradictions(cards) {
25
+ const issues = [];
26
+ const axioms = cards.filter(c => c.type === 'axiom' && c.locked);
27
+
28
+ // Check for missing boundaries on locked axioms
29
+ for (const ax of axioms) {
30
+ if (!ax.fields?.does_not_apply_when || ax.fields.does_not_apply_when.length === 0) {
31
+ issues.push({
32
+ type: 'missing_boundary',
33
+ card_id: ax.id,
34
+ severity: 'blocking',
35
+ message: `${ax.id}: locked axiom lacks does_not_apply_when`,
36
+ fix: 'Define at least one situation where this axiom should NOT be applied.',
37
+ });
38
+ }
39
+ if (!ax.fields?.applies_when || ax.fields.applies_when.length === 0) {
40
+ issues.push({
41
+ type: 'missing_applicability',
42
+ card_id: ax.id,
43
+ severity: 'blocking',
44
+ message: `${ax.id}: locked axiom lacks applies_when`,
45
+ fix: 'Define at least one situation where this axiom applies.',
46
+ });
47
+ }
48
+ if (ax.fields?.full_statement && ax.fields.full_statement.length < 30) {
49
+ issues.push({
50
+ type: 'too_short',
51
+ card_id: ax.id,
52
+ severity: 'warning',
53
+ message: `${ax.id}: full_statement is very short — may be too vague`,
54
+ });
55
+ }
56
+
57
+ // Check for over-generalization: axioms that use absolute language without boundaries
58
+ const oneLiner = (ax.fields?.one_sentence || '').toLowerCase();
59
+ if (/\b(always|never|every|all|none|must)\b/.test(oneLiner) && (!ax.fields.does_not_apply_when || ax.fields.does_not_apply_when.length === 0)) {
60
+ issues.push({
61
+ type: 'overgeneralized',
62
+ card_id: ax.id,
63
+ severity: 'warning',
64
+ message: `${ax.id}: uses absolute language ("${oneLiner.match(/always|never|every|all|none|must/)[0]}") but has no does_not_apply_when`,
65
+ fix: 'Add does_not_apply_when to prevent this axiom from being applied universally.',
66
+ });
67
+ }
68
+ }
69
+
70
+ // Check misunderstandings
71
+ const misunderstandings = cards.filter(c => c.type === 'misunderstanding' && c.locked);
72
+ for (const ms of misunderstandings) {
73
+ const wrong = ms.fields?.wrong || '';
74
+ const correct = ms.fields?.correct || '';
75
+
76
+ // Check for straw-man: the "wrong" belief describes something no real person would believe
77
+ if (wrong.length < 15) {
78
+ issues.push({
79
+ type: 'vague_misunderstanding',
80
+ card_id: ms.id,
81
+ severity: 'warning',
82
+ message: `${ms.id}: wrong belief description is very short — may not describe a real mistake`,
83
+ });
84
+ }
85
+
86
+ // Check the wrong and correct are actually different (not just negation)
87
+ const wrongWords = new Set(tokenize(wrong.toLowerCase()));
88
+ const correctWords = tokenize(correct.toLowerCase());
89
+ const sharedWords = correctWords.filter(w => wrongWords.has(w)).length;
90
+ if (correctWords.length > 0 && sharedWords / correctWords.length > 0.7) {
91
+ issues.push({
92
+ type: 'weak_distinction',
93
+ card_id: ms.id,
94
+ severity: 'warning',
95
+ message: `${ms.id}: wrong and correct share ${Math.round(sharedWords / correctWords.length * 100)}% of words — distinction may be too weak`,
96
+ });
97
+ }
98
+
99
+ // Key distinction check
100
+ if (!ms.fields?.key_distinction || ms.fields.key_distinction.length < 20) {
101
+ issues.push({
102
+ type: 'missing_distinction',
103
+ card_id: ms.id,
104
+ severity: 'blocking',
105
+ message: `${ms.id}: key_distinction missing or too short`,
106
+ fix: 'Explain the conceptual boundary between the wrong belief and the correct one.',
107
+ });
108
+ }
109
+ }
110
+
111
+ // Check self-checks
112
+ const selfChecks = cards.filter(c => c.type === 'self_check' && c.locked);
113
+ for (const sc of selfChecks) {
114
+ const question = sc.fields?.question || '';
115
+ if (!question.trim().endsWith('?')) {
116
+ issues.push({
117
+ type: 'not_a_question',
118
+ card_id: sc.id,
119
+ severity: 'blocking',
120
+ message: `${sc.id}: self_check must be phrased as a question ending with ?`,
121
+ });
122
+ }
123
+ if (question.length < 15) {
124
+ issues.push({
125
+ type: 'vague_check',
126
+ card_id: sc.id,
127
+ severity: 'warning',
128
+ message: `${sc.id}: self_check question is very short — may be too vague to verify`,
129
+ });
130
+ }
131
+ // Check for generic self-checks
132
+ const genericPatterns = ['is this good', 'is this correct', 'is this helpful', 'is this clear', 'is this response', 'is the response', 'good enough', 'is it good'];
133
+ if (genericPatterns.some(p => question.toLowerCase().includes(p))) {
134
+ issues.push({
135
+ type: 'generic_check',
136
+ card_id: sc.id,
137
+ severity: 'warning',
138
+ message: `${sc.id}: self_check is too generic — should be domain-specific`,
139
+ fix: 'Rephrase with domain-specific criteria, e.g. "Did the agent diagnose the type of uncertainty before suggesting action?"',
140
+ });
141
+ }
142
+ }
143
+
144
+ // Check boundaries
145
+ const boundaries = cards.filter(c => c.type === 'boundary' && c.locked);
146
+ for (const bd of boundaries) {
147
+ if (bd.fields?.out_of_scope && bd.fields.out_of_scope.length < 10) {
148
+ issues.push({
149
+ type: 'vague_boundary',
150
+ card_id: bd.id,
151
+ severity: 'warning',
152
+ message: `${bd.id}: out_of_scope is very short — boundary may be unclear`,
153
+ });
154
+ }
155
+ if (!bd.fields?.acceptable_exceptions || bd.fields.acceptable_exceptions.length === 0) {
156
+ issues.push({
157
+ type: 'no_exceptions',
158
+ card_id: bd.id,
159
+ severity: 'warning',
160
+ message: `${bd.id}: no acceptable_exceptions declared — every boundary has justified exceptions`,
161
+ });
162
+ }
163
+ }
164
+
165
+ return issues;
166
+ }
167
+
168
+ function summarizeContradictions(issues) {
169
+ const blocking = issues.filter(i => i.severity === 'blocking');
170
+ const warnings = issues.filter(i => i.severity === 'warning');
171
+ return {
172
+ total: issues.length,
173
+ blocking: blocking.length,
174
+ warnings: warnings.length,
175
+ clean: issues.length === 0,
176
+ by_type: issues.reduce((acc, i) => {
177
+ acc[i.type] = (acc[i.type] || 0) + 1;
178
+ return acc;
179
+ }, {}),
180
+ };
181
+ }
182
+
183
+ module.exports = { detectContradictions, summarizeContradictions };