@unrdf/project-engine 5.0.1
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/LICENSE +21 -0
- package/README.md +53 -0
- package/package.json +58 -0
- package/src/api-contract-validator.mjs +711 -0
- package/src/auto-test-generator.mjs +444 -0
- package/src/autonomic-mapek.mjs +511 -0
- package/src/capabilities-manifest.mjs +125 -0
- package/src/code-complexity-js.mjs +368 -0
- package/src/dependency-graph.mjs +276 -0
- package/src/doc-drift-checker.mjs +172 -0
- package/src/doc-generator.mjs +229 -0
- package/src/domain-infer.mjs +966 -0
- package/src/drift-snapshot.mjs +775 -0
- package/src/file-roles.mjs +94 -0
- package/src/fs-scan.mjs +305 -0
- package/src/gap-finder.mjs +376 -0
- package/src/golden-structure.mjs +149 -0
- package/src/hotspot-analyzer.mjs +412 -0
- package/src/index.mjs +151 -0
- package/src/initialize.mjs +957 -0
- package/src/lens/project-structure.mjs +74 -0
- package/src/mapek-orchestration.mjs +665 -0
- package/src/materialize-apply.mjs +505 -0
- package/src/materialize-plan.mjs +422 -0
- package/src/materialize.mjs +137 -0
- package/src/policy-derivation.mjs +869 -0
- package/src/project-config.mjs +142 -0
- package/src/project-diff.mjs +28 -0
- package/src/project-engine/build-utils.mjs +237 -0
- package/src/project-engine/code-analyzer.mjs +248 -0
- package/src/project-engine/doc-generator.mjs +407 -0
- package/src/project-engine/infrastructure.mjs +213 -0
- package/src/project-engine/metrics.mjs +146 -0
- package/src/project-model.mjs +111 -0
- package/src/project-report.mjs +348 -0
- package/src/refactoring-guide.mjs +242 -0
- package/src/stack-detect.mjs +102 -0
- package/src/stack-linter.mjs +213 -0
- package/src/template-infer.mjs +674 -0
- package/src/type-auditor.mjs +609 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Drift detection system - baseline snapshots and deviation detection
|
|
3
|
+
* @module project-engine/drift-snapshot
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { UnrdfDataFactory as DataFactory } from '@unrdf/core/rdf/n3-justified-only';
|
|
8
|
+
import { createStore } from '@unrdf/oxigraph'; // TODO: Replace with Oxigraph Store
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import {
|
|
11
|
+
diffGraphFromStores,
|
|
12
|
+
summarizeChangesByKind as _summarizeChangesByKind,
|
|
13
|
+
} from '../diff.mjs';
|
|
14
|
+
import { ProjectStructureLens } from './lens/project-structure.mjs';
|
|
15
|
+
|
|
16
|
+
const { namedNode, literal } = DataFactory;
|
|
17
|
+
|
|
18
|
+
/* ========================================================================= */
|
|
19
|
+
/* Zod Schemas */
|
|
20
|
+
/* ========================================================================= */
|
|
21
|
+
|
|
22
|
+
const CreateSnapshotOptionsSchema = z.object({
|
|
23
|
+
fsStore: z.object({}).passthrough(),
|
|
24
|
+
domainStore: z.object({}).passthrough().optional().nullable(),
|
|
25
|
+
templateMappings: z.record(z.string(), z.string()).optional(),
|
|
26
|
+
baseIri: z.string().default('http://example.org/unrdf/snapshot#'),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const SnapshotReceiptSchema = z.object({
|
|
30
|
+
hash: z.string(),
|
|
31
|
+
createdAt: z.string(),
|
|
32
|
+
summary: z.object({
|
|
33
|
+
fileCount: z.number(),
|
|
34
|
+
featureCount: z.number(),
|
|
35
|
+
roleCount: z.number(),
|
|
36
|
+
domainEntityCount: z.number(),
|
|
37
|
+
templateMappingCount: z.number(),
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const DriftResultSchema = z.object({
|
|
42
|
+
ontologyDiff: z.object({
|
|
43
|
+
triples: z.object({
|
|
44
|
+
added: z.array(z.any()),
|
|
45
|
+
removed: z.array(z.any()),
|
|
46
|
+
}),
|
|
47
|
+
changes: z.array(z.any()),
|
|
48
|
+
}),
|
|
49
|
+
summary: z.array(z.string()),
|
|
50
|
+
driftSeverity: z.enum(['none', 'minor', 'major']),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/* ========================================================================= */
|
|
54
|
+
/* Snapshot Creation */
|
|
55
|
+
/* ========================================================================= */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a structure snapshot from filesystem and domain stores
|
|
59
|
+
* Encodes: FS structure + domain + template mappings
|
|
60
|
+
*
|
|
61
|
+
* @param {Store} fsStore - Filesystem structure store
|
|
62
|
+
* @param {Store} [domainStore] - Domain ontology store (entities + fields)
|
|
63
|
+
* @param {Object} [options] - Snapshot options
|
|
64
|
+
* @param {Record<string, string>} [options.templateMappings] - File to template mappings
|
|
65
|
+
* @param {string} [options.baseIri] - Base IRI for snapshot resources
|
|
66
|
+
* @returns {{snapshotStore: Store, receipt: {hash: string, createdAt: string, summary: Object}}}
|
|
67
|
+
*/
|
|
68
|
+
export function createStructureSnapshot(fsStore, domainStore, options = {}) {
|
|
69
|
+
const validated = CreateSnapshotOptionsSchema.parse({
|
|
70
|
+
fsStore,
|
|
71
|
+
domainStore,
|
|
72
|
+
...options,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const { baseIri, templateMappings = {} } = validated;
|
|
76
|
+
const snapshotStore = createStore();
|
|
77
|
+
const createdAt = new Date().toISOString();
|
|
78
|
+
|
|
79
|
+
// Summary counters
|
|
80
|
+
const summary = {
|
|
81
|
+
fileCount: 0,
|
|
82
|
+
featureCount: 0,
|
|
83
|
+
roleCount: 0,
|
|
84
|
+
domainEntityCount: 0,
|
|
85
|
+
templateMappingCount: 0,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// 1. Copy FS structure graph (file -> feature -> role)
|
|
89
|
+
const fsQuads = fsStore.getQuads(null, null, null, null);
|
|
90
|
+
for (const quad of fsQuads) {
|
|
91
|
+
snapshotStore.addQuad(quad.subject, quad.predicate, quad.object, quad.graph);
|
|
92
|
+
|
|
93
|
+
// Count files
|
|
94
|
+
if (
|
|
95
|
+
quad.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' &&
|
|
96
|
+
quad.object.value ===
|
|
97
|
+
'http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject'
|
|
98
|
+
) {
|
|
99
|
+
summary.fileCount++;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Count features
|
|
103
|
+
if (
|
|
104
|
+
quad.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' &&
|
|
105
|
+
quad.object.value === 'http://example.org/unrdf/project#Feature'
|
|
106
|
+
) {
|
|
107
|
+
summary.featureCount++;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Count roles
|
|
111
|
+
if (quad.predicate.value === 'http://example.org/unrdf/project#hasRole') {
|
|
112
|
+
summary.roleCount++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Copy domain ontology if provided
|
|
117
|
+
if (domainStore) {
|
|
118
|
+
const domainQuads = domainStore.getQuads(null, null, null, null);
|
|
119
|
+
for (const quad of domainQuads) {
|
|
120
|
+
snapshotStore.addQuad(quad.subject, quad.predicate, quad.object, quad.graph);
|
|
121
|
+
|
|
122
|
+
// Count domain entities
|
|
123
|
+
if (
|
|
124
|
+
quad.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' &&
|
|
125
|
+
(quad.object.value.includes('Entity') || quad.object.value.includes('Class'))
|
|
126
|
+
) {
|
|
127
|
+
summary.domainEntityCount++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 3. Encode template mappings (file -> template -> entity)
|
|
133
|
+
for (const [filePath, templateId] of Object.entries(templateMappings)) {
|
|
134
|
+
const fileIri = namedNode(`${baseIri}file/${encodeURIComponent(filePath)}`);
|
|
135
|
+
const templateIri = namedNode(`${baseIri}template/${encodeURIComponent(templateId)}`);
|
|
136
|
+
|
|
137
|
+
snapshotStore.addQuad(
|
|
138
|
+
fileIri,
|
|
139
|
+
namedNode('http://example.org/unrdf/project#usesTemplate'),
|
|
140
|
+
templateIri
|
|
141
|
+
);
|
|
142
|
+
summary.templateMappingCount++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Add snapshot metadata
|
|
146
|
+
const snapshotIri = namedNode(`${baseIri}snapshot`);
|
|
147
|
+
snapshotStore.addQuad(
|
|
148
|
+
snapshotIri,
|
|
149
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
150
|
+
namedNode('http://example.org/unrdf/snapshot#StructureSnapshot')
|
|
151
|
+
);
|
|
152
|
+
snapshotStore.addQuad(
|
|
153
|
+
snapshotIri,
|
|
154
|
+
namedNode('http://example.org/unrdf/snapshot#createdAt'),
|
|
155
|
+
literal(createdAt, namedNode('http://www.w3.org/2001/XMLSchema#dateTime'))
|
|
156
|
+
);
|
|
157
|
+
snapshotStore.addQuad(
|
|
158
|
+
snapshotIri,
|
|
159
|
+
namedNode('http://example.org/unrdf/snapshot#fileCount'),
|
|
160
|
+
literal(summary.fileCount, namedNode('http://www.w3.org/2001/XMLSchema#integer'))
|
|
161
|
+
);
|
|
162
|
+
snapshotStore.addQuad(
|
|
163
|
+
snapshotIri,
|
|
164
|
+
namedNode('http://example.org/unrdf/snapshot#featureCount'),
|
|
165
|
+
literal(summary.featureCount, namedNode('http://www.w3.org/2001/XMLSchema#integer'))
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// 5. Compute hash of combined state
|
|
169
|
+
const hash = computeSnapshotHash(snapshotStore, createdAt);
|
|
170
|
+
|
|
171
|
+
snapshotStore.addQuad(
|
|
172
|
+
snapshotIri,
|
|
173
|
+
namedNode('http://example.org/unrdf/snapshot#contentHash'),
|
|
174
|
+
literal(hash)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const receipt = SnapshotReceiptSchema.parse({
|
|
178
|
+
hash,
|
|
179
|
+
createdAt,
|
|
180
|
+
summary,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return { snapshotStore, receipt };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ========================================================================= */
|
|
187
|
+
/* Drift Detection */
|
|
188
|
+
/* ========================================================================= */
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Compute drift between current snapshot and baseline
|
|
192
|
+
* Uses diffOntologyFromDelta + ProjectStructureLens
|
|
193
|
+
*
|
|
194
|
+
* @param {Store} currentSnapshot - Current project state snapshot
|
|
195
|
+
* @param {Store} baselineSnapshot - Baseline snapshot to compare against
|
|
196
|
+
* @returns {{ontologyDiff: Object, summary: string[], driftSeverity: 'none'|'minor'|'major'}}
|
|
197
|
+
*/
|
|
198
|
+
export function computeDrift(currentSnapshot, baselineSnapshot) {
|
|
199
|
+
// Compute graph-level diff
|
|
200
|
+
const graphDiff = diffGraphFromStores(baselineSnapshot, currentSnapshot);
|
|
201
|
+
|
|
202
|
+
// Apply ProjectStructureLens to get semantic changes
|
|
203
|
+
const changes = [];
|
|
204
|
+
|
|
205
|
+
for (const triple of graphDiff.added) {
|
|
206
|
+
const change = ProjectStructureLens(triple, 'added');
|
|
207
|
+
if (change) {
|
|
208
|
+
changes.push(change);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const triple of graphDiff.removed) {
|
|
213
|
+
const change = ProjectStructureLens(triple, 'removed');
|
|
214
|
+
if (change) {
|
|
215
|
+
changes.push(change);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const ontologyDiff = {
|
|
220
|
+
triples: graphDiff,
|
|
221
|
+
changes,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Generate human-readable summary
|
|
225
|
+
const summary = generateDriftSummary(graphDiff, changes, currentSnapshot, baselineSnapshot);
|
|
226
|
+
|
|
227
|
+
// Compute severity
|
|
228
|
+
const driftSeverity = computeDriftSeverity(graphDiff, changes);
|
|
229
|
+
|
|
230
|
+
return DriftResultSchema.parse({
|
|
231
|
+
ontologyDiff,
|
|
232
|
+
summary,
|
|
233
|
+
driftSeverity,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ========================================================================= */
|
|
238
|
+
/* Drift Summary Generation */
|
|
239
|
+
/* ========================================================================= */
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Generate human-readable drift summary
|
|
243
|
+
*
|
|
244
|
+
* @param {Object} graphDiff - Low-level triple diff
|
|
245
|
+
* @param {Object[]} changes - Semantic changes from lens
|
|
246
|
+
* @param {Store} currentSnapshot - Current snapshot
|
|
247
|
+
* @param {Store} baselineSnapshot - Baseline snapshot
|
|
248
|
+
* @returns {string[]}
|
|
249
|
+
*/
|
|
250
|
+
function generateDriftSummary(graphDiff, changes, currentSnapshot, baselineSnapshot) {
|
|
251
|
+
const summary = [];
|
|
252
|
+
|
|
253
|
+
// Group changes by kind
|
|
254
|
+
const changesByKind = {};
|
|
255
|
+
for (const change of changes) {
|
|
256
|
+
if (!changesByKind[change.kind]) {
|
|
257
|
+
changesByKind[change.kind] = [];
|
|
258
|
+
}
|
|
259
|
+
changesByKind[change.kind].push(change);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Missing tests detection
|
|
263
|
+
const missingTests = detectMissingTests(graphDiff, changes, currentSnapshot, baselineSnapshot);
|
|
264
|
+
for (const msg of missingTests) {
|
|
265
|
+
summary.push(msg);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Unmatched template patterns
|
|
269
|
+
const unmatchedPatterns = detectUnmatchedPatterns(graphDiff, changes, currentSnapshot);
|
|
270
|
+
for (const msg of unmatchedPatterns) {
|
|
271
|
+
summary.push(msg);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Domain entity coverage gaps
|
|
275
|
+
const coverageGaps = detectCoverageGaps(graphDiff, changes, currentSnapshot, baselineSnapshot);
|
|
276
|
+
for (const msg of coverageGaps) {
|
|
277
|
+
summary.push(msg);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Test coverage changes
|
|
281
|
+
const coverageChanges = detectTestCoverageChanges(currentSnapshot, baselineSnapshot);
|
|
282
|
+
for (const msg of coverageChanges) {
|
|
283
|
+
summary.push(msg);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Feature additions/removals
|
|
287
|
+
if (changesByKind['FeatureAdded']) {
|
|
288
|
+
for (const change of changesByKind['FeatureAdded']) {
|
|
289
|
+
const name = extractNameFromIri(change.entity);
|
|
290
|
+
summary.push(`Feature "${name}" added since baseline`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (changesByKind['FeatureRemoved']) {
|
|
295
|
+
for (const change of changesByKind['FeatureRemoved']) {
|
|
296
|
+
const name = extractNameFromIri(change.entity);
|
|
297
|
+
summary.push(`Feature "${name}" removed (was in baseline)`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Role changes
|
|
302
|
+
if (changesByKind['RoleRemoved']) {
|
|
303
|
+
for (const change of changesByKind['RoleRemoved']) {
|
|
304
|
+
const entity = extractNameFromIri(change.entity);
|
|
305
|
+
const role = extractNameFromIri(change.role);
|
|
306
|
+
summary.push(`Role "${role}" removed from "${entity}"`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// If no drift detected
|
|
311
|
+
if (summary.length === 0 && graphDiff.added.length === 0 && graphDiff.removed.length === 0) {
|
|
312
|
+
summary.push('No structural drift detected - code matches baseline model');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return summary;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Detect features missing required tests
|
|
320
|
+
*
|
|
321
|
+
* @param {Object} graphDiff
|
|
322
|
+
* @param {Object[]} changes
|
|
323
|
+
* @param {Store} currentSnapshot
|
|
324
|
+
* @param {Store} baselineSnapshot
|
|
325
|
+
* @returns {string[]}
|
|
326
|
+
*/
|
|
327
|
+
function detectMissingTests(graphDiff, changes, currentSnapshot, baselineSnapshot) {
|
|
328
|
+
const messages = [];
|
|
329
|
+
|
|
330
|
+
// Get features in current snapshot
|
|
331
|
+
const currentFeatures = new Set();
|
|
332
|
+
const featureQuads = currentSnapshot.getQuads(
|
|
333
|
+
null,
|
|
334
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
335
|
+
namedNode('http://example.org/unrdf/project#Feature')
|
|
336
|
+
);
|
|
337
|
+
for (const quad of featureQuads) {
|
|
338
|
+
currentFeatures.add(quad.subject.value);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Check which features have tests
|
|
342
|
+
const featuresWithTests = new Set();
|
|
343
|
+
const testRoleQuads = currentSnapshot.getQuads(
|
|
344
|
+
null,
|
|
345
|
+
namedNode('http://example.org/unrdf/project#hasRole'),
|
|
346
|
+
namedNode('http://example.org/unrdf/project#Test')
|
|
347
|
+
);
|
|
348
|
+
for (const quad of testRoleQuads) {
|
|
349
|
+
// Find feature this file belongs to
|
|
350
|
+
const belongsQuads = currentSnapshot.getQuads(
|
|
351
|
+
quad.subject,
|
|
352
|
+
namedNode('http://example.org/unrdf/project#belongsToFeature'),
|
|
353
|
+
null
|
|
354
|
+
);
|
|
355
|
+
for (const bq of belongsQuads) {
|
|
356
|
+
featuresWithTests.add(bq.object.value);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check baseline for features that should have tests
|
|
361
|
+
const baselineTestRequirements = new Set();
|
|
362
|
+
const baselineTestQuads = baselineSnapshot.getQuads(
|
|
363
|
+
null,
|
|
364
|
+
namedNode('http://example.org/unrdf/project#hasRole'),
|
|
365
|
+
namedNode('http://example.org/unrdf/project#Test')
|
|
366
|
+
);
|
|
367
|
+
for (const quad of baselineTestQuads) {
|
|
368
|
+
const belongsQuads = baselineSnapshot.getQuads(
|
|
369
|
+
quad.subject,
|
|
370
|
+
namedNode('http://example.org/unrdf/project#belongsToFeature'),
|
|
371
|
+
null
|
|
372
|
+
);
|
|
373
|
+
for (const bq of belongsQuads) {
|
|
374
|
+
baselineTestRequirements.add(bq.object.value);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Report features that had tests in baseline but not now
|
|
379
|
+
for (const featureIri of baselineTestRequirements) {
|
|
380
|
+
if (currentFeatures.has(featureIri) && !featuresWithTests.has(featureIri)) {
|
|
381
|
+
const name = extractNameFromIri(featureIri);
|
|
382
|
+
messages.push(`Feature "${name}" missing tests when model requires them`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return messages;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Detect files that don't match any template pattern
|
|
391
|
+
*
|
|
392
|
+
* @param {Object} graphDiff
|
|
393
|
+
* @param {Object[]} changes
|
|
394
|
+
* @param {Store} currentSnapshot
|
|
395
|
+
* @returns {string[]}
|
|
396
|
+
*/
|
|
397
|
+
function detectUnmatchedPatterns(graphDiff, changes, currentSnapshot) {
|
|
398
|
+
const messages = [];
|
|
399
|
+
|
|
400
|
+
// Find files without roles
|
|
401
|
+
const filesWithRoles = new Set();
|
|
402
|
+
const roleQuads = currentSnapshot.getQuads(
|
|
403
|
+
null,
|
|
404
|
+
namedNode('http://example.org/unrdf/project#hasRole'),
|
|
405
|
+
null
|
|
406
|
+
);
|
|
407
|
+
for (const quad of roleQuads) {
|
|
408
|
+
filesWithRoles.add(quad.subject.value);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Find all files in feature directories
|
|
412
|
+
const featureFiles = [];
|
|
413
|
+
const pathQuads = currentSnapshot.getQuads(
|
|
414
|
+
null,
|
|
415
|
+
namedNode('http://example.org/unrdf/filesystem#relativePath'),
|
|
416
|
+
null
|
|
417
|
+
);
|
|
418
|
+
for (const quad of pathQuads) {
|
|
419
|
+
const path = quad.object.value;
|
|
420
|
+
if (path.includes('/features/') || path.includes('/modules/')) {
|
|
421
|
+
if (!filesWithRoles.has(quad.subject.value)) {
|
|
422
|
+
featureFiles.push(path);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Group unmatched files by feature
|
|
428
|
+
const unmatchedByFeature = {};
|
|
429
|
+
for (const path of featureFiles) {
|
|
430
|
+
const match = path.match(/\/(features|modules)\/([^/]+)\//);
|
|
431
|
+
if (match) {
|
|
432
|
+
const featureName = match[2];
|
|
433
|
+
if (!unmatchedByFeature[featureName]) {
|
|
434
|
+
unmatchedByFeature[featureName] = [];
|
|
435
|
+
}
|
|
436
|
+
unmatchedByFeature[featureName].push(path);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const [featureName, files] of Object.entries(unmatchedByFeature)) {
|
|
441
|
+
if (files.length > 0) {
|
|
442
|
+
messages.push(
|
|
443
|
+
`Files in features/${featureName} don't match any template pattern (${files.length} files)`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return messages;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Detect domain entities without corresponding views/APIs
|
|
453
|
+
*
|
|
454
|
+
* @param {Object} graphDiff
|
|
455
|
+
* @param {Object[]} changes
|
|
456
|
+
* @param {Store} currentSnapshot
|
|
457
|
+
* @param {Store} baselineSnapshot
|
|
458
|
+
* @returns {string[]}
|
|
459
|
+
*/
|
|
460
|
+
function detectCoverageGaps(graphDiff, changes, currentSnapshot, baselineSnapshot) {
|
|
461
|
+
const messages = [];
|
|
462
|
+
|
|
463
|
+
// Find domain entities in current snapshot
|
|
464
|
+
const currentEntities = new Set();
|
|
465
|
+
const entityQuads = currentSnapshot.getQuads(
|
|
466
|
+
null,
|
|
467
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
468
|
+
null
|
|
469
|
+
);
|
|
470
|
+
for (const quad of entityQuads) {
|
|
471
|
+
if (quad.object.value.includes('Entity') || quad.object.value.includes('DomainClass')) {
|
|
472
|
+
currentEntities.add(quad.subject.value);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Find entities in baseline
|
|
477
|
+
const baselineEntities = new Set();
|
|
478
|
+
const baselineEntityQuads = baselineSnapshot.getQuads(
|
|
479
|
+
null,
|
|
480
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
481
|
+
null
|
|
482
|
+
);
|
|
483
|
+
for (const quad of baselineEntityQuads) {
|
|
484
|
+
if (quad.object.value.includes('Entity') || quad.object.value.includes('DomainClass')) {
|
|
485
|
+
baselineEntities.add(quad.subject.value);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Find new entities (in current but not baseline)
|
|
490
|
+
for (const entityIri of currentEntities) {
|
|
491
|
+
if (!baselineEntities.has(entityIri)) {
|
|
492
|
+
// Check if entity has views/APIs
|
|
493
|
+
const hasViews = checkEntityHasViews(entityIri, currentSnapshot);
|
|
494
|
+
const hasApis = checkEntityHasApis(entityIri, currentSnapshot);
|
|
495
|
+
|
|
496
|
+
if (!hasViews && !hasApis) {
|
|
497
|
+
const name = extractNameFromIri(entityIri);
|
|
498
|
+
messages.push(`Domain entity "${name}" added but no views/APIs for it`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return messages;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Detect test coverage changes
|
|
508
|
+
*
|
|
509
|
+
* @param {Store} currentSnapshot
|
|
510
|
+
* @param {Store} baselineSnapshot
|
|
511
|
+
* @returns {string[]}
|
|
512
|
+
*/
|
|
513
|
+
function detectTestCoverageChanges(currentSnapshot, baselineSnapshot) {
|
|
514
|
+
const messages = [];
|
|
515
|
+
|
|
516
|
+
// Count tests in baseline
|
|
517
|
+
const baselineTestCount = baselineSnapshot.getQuads(
|
|
518
|
+
null,
|
|
519
|
+
namedNode('http://example.org/unrdf/project#hasRole'),
|
|
520
|
+
namedNode('http://example.org/unrdf/project#Test')
|
|
521
|
+
).length;
|
|
522
|
+
|
|
523
|
+
// Count tests in current
|
|
524
|
+
const currentTestCount = currentSnapshot.getQuads(
|
|
525
|
+
null,
|
|
526
|
+
namedNode('http://example.org/unrdf/project#hasRole'),
|
|
527
|
+
namedNode('http://example.org/unrdf/project#Test')
|
|
528
|
+
).length;
|
|
529
|
+
|
|
530
|
+
// Count total files
|
|
531
|
+
const baselineFileCount = baselineSnapshot.getQuads(
|
|
532
|
+
null,
|
|
533
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
534
|
+
namedNode('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject')
|
|
535
|
+
).length;
|
|
536
|
+
|
|
537
|
+
const currentFileCount = currentSnapshot.getQuads(
|
|
538
|
+
null,
|
|
539
|
+
namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'),
|
|
540
|
+
namedNode('http://www.semanticdesktop.org/ontologies/2007/03/22/nfo#FileDataObject')
|
|
541
|
+
).length;
|
|
542
|
+
|
|
543
|
+
// Compute coverage ratios
|
|
544
|
+
const baselineCoverage = baselineFileCount > 0 ? baselineTestCount / baselineFileCount : 0;
|
|
545
|
+
const currentCoverage = currentFileCount > 0 ? currentTestCount / currentFileCount : 0;
|
|
546
|
+
|
|
547
|
+
// Report if coverage dropped
|
|
548
|
+
if (currentCoverage < baselineCoverage * 0.9) {
|
|
549
|
+
const baselinePct = (baselineCoverage * 100).toFixed(1);
|
|
550
|
+
const currentPct = (currentCoverage * 100).toFixed(1);
|
|
551
|
+
messages.push(`Test coverage dropped below baseline (was ${baselinePct}%, now ${currentPct}%)`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return messages;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Check if entity has view files
|
|
559
|
+
*
|
|
560
|
+
* @param {string} entityIri
|
|
561
|
+
* @param {Store} store
|
|
562
|
+
* @returns {boolean}
|
|
563
|
+
*/
|
|
564
|
+
function checkEntityHasViews(entityIri, store) {
|
|
565
|
+
const entityName = extractNameFromIri(entityIri).toLowerCase();
|
|
566
|
+
|
|
567
|
+
// Look for view/component files containing entity name
|
|
568
|
+
const pathQuads = store.getQuads(
|
|
569
|
+
null,
|
|
570
|
+
namedNode('http://example.org/unrdf/filesystem#relativePath'),
|
|
571
|
+
null
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
for (const quad of pathQuads) {
|
|
575
|
+
const path = quad.object.value.toLowerCase();
|
|
576
|
+
if (
|
|
577
|
+
path.includes(entityName) &&
|
|
578
|
+
(path.includes('view') || path.includes('page') || path.includes('component'))
|
|
579
|
+
) {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Check if entity has API files
|
|
589
|
+
*
|
|
590
|
+
* @param {string} entityIri
|
|
591
|
+
* @param {Store} store
|
|
592
|
+
* @returns {boolean}
|
|
593
|
+
*/
|
|
594
|
+
function checkEntityHasApis(entityIri, store) {
|
|
595
|
+
const entityName = extractNameFromIri(entityIri).toLowerCase();
|
|
596
|
+
|
|
597
|
+
// Look for API/route files containing entity name
|
|
598
|
+
const pathQuads = store.getQuads(
|
|
599
|
+
null,
|
|
600
|
+
namedNode('http://example.org/unrdf/filesystem#relativePath'),
|
|
601
|
+
null
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
for (const quad of pathQuads) {
|
|
605
|
+
const path = quad.object.value.toLowerCase();
|
|
606
|
+
if (
|
|
607
|
+
path.includes(entityName) &&
|
|
608
|
+
(path.includes('api') || path.includes('route') || path.includes('controller'))
|
|
609
|
+
) {
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ========================================================================= */
|
|
618
|
+
/* Drift Severity Computation */
|
|
619
|
+
/* ========================================================================= */
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Compute drift severity based on changes
|
|
623
|
+
*
|
|
624
|
+
* @param {Object} graphDiff
|
|
625
|
+
* @param {Object[]} changes
|
|
626
|
+
* @returns {'none'|'minor'|'major'}
|
|
627
|
+
*/
|
|
628
|
+
function computeDriftSeverity(graphDiff, changes) {
|
|
629
|
+
const totalTripleChanges = graphDiff.added.length + graphDiff.removed.length;
|
|
630
|
+
|
|
631
|
+
// No changes = no drift
|
|
632
|
+
if (totalTripleChanges === 0 && changes.length === 0) {
|
|
633
|
+
return 'none';
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Count critical changes
|
|
637
|
+
let criticalCount = 0;
|
|
638
|
+
let minorCount = 0;
|
|
639
|
+
|
|
640
|
+
for (const change of changes) {
|
|
641
|
+
if (change.kind === 'FeatureRemoved' || change.kind === 'ModuleRemoved') {
|
|
642
|
+
criticalCount++;
|
|
643
|
+
} else if (change.kind === 'RoleRemoved') {
|
|
644
|
+
// Removed tests or docs is critical
|
|
645
|
+
if (change.role && (change.role.includes('Test') || change.role.includes('Doc'))) {
|
|
646
|
+
criticalCount++;
|
|
647
|
+
} else {
|
|
648
|
+
minorCount++;
|
|
649
|
+
}
|
|
650
|
+
} else {
|
|
651
|
+
minorCount++;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Major if any critical changes or many minor changes
|
|
656
|
+
if (criticalCount > 0 || minorCount > 10) {
|
|
657
|
+
return 'major';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Minor if few changes
|
|
661
|
+
if (minorCount > 0 || totalTripleChanges > 0) {
|
|
662
|
+
return 'minor';
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return 'none';
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/* ========================================================================= */
|
|
669
|
+
/* Utility Functions */
|
|
670
|
+
/* ========================================================================= */
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Compute SHA-256 hash of snapshot
|
|
674
|
+
*
|
|
675
|
+
* @param {Store} store
|
|
676
|
+
* @param {string} timestamp
|
|
677
|
+
* @returns {string}
|
|
678
|
+
*/
|
|
679
|
+
function computeSnapshotHash(store, timestamp) {
|
|
680
|
+
const hash = createHash('sha256');
|
|
681
|
+
|
|
682
|
+
// Hash store size and timestamp
|
|
683
|
+
hash.update(String(store.size));
|
|
684
|
+
hash.update(timestamp);
|
|
685
|
+
|
|
686
|
+
// Hash a sample of quads for content fingerprint
|
|
687
|
+
const quads = store.getQuads(null, null, null, null);
|
|
688
|
+
const sample = quads.slice(0, Math.min(100, quads.length));
|
|
689
|
+
|
|
690
|
+
for (const quad of sample) {
|
|
691
|
+
hash.update(quad.subject.value);
|
|
692
|
+
hash.update(quad.predicate.value);
|
|
693
|
+
hash.update(quad.object.value);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return hash.digest('hex').substring(0, 32);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Extract name from IRI
|
|
701
|
+
*
|
|
702
|
+
* @param {string} iri
|
|
703
|
+
* @returns {string}
|
|
704
|
+
*/
|
|
705
|
+
function extractNameFromIri(iri) {
|
|
706
|
+
if (!iri) return 'unknown';
|
|
707
|
+
|
|
708
|
+
// Try hash fragment
|
|
709
|
+
const hashMatch = iri.match(/#([^#]+)$/);
|
|
710
|
+
if (hashMatch) return decodeURIComponent(hashMatch[1]);
|
|
711
|
+
|
|
712
|
+
// Try last path segment
|
|
713
|
+
const slashMatch = iri.match(/\/([^/]+)$/);
|
|
714
|
+
if (slashMatch) return decodeURIComponent(slashMatch[1]);
|
|
715
|
+
|
|
716
|
+
return iri;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/* ========================================================================= */
|
|
720
|
+
/* Convenience Exports */
|
|
721
|
+
/* ========================================================================= */
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Create empty baseline snapshot
|
|
725
|
+
*
|
|
726
|
+
* @returns {Store}
|
|
727
|
+
*/
|
|
728
|
+
export function createEmptyBaseline() {
|
|
729
|
+
return createStore();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Serialize snapshot to JSON for persistence
|
|
734
|
+
*
|
|
735
|
+
* @param {Store} snapshotStore
|
|
736
|
+
* @param {Object} receipt
|
|
737
|
+
* @returns {string}
|
|
738
|
+
*/
|
|
739
|
+
export function serializeSnapshot(snapshotStore, receipt) {
|
|
740
|
+
const quads = snapshotStore.getQuads(null, null, null, null);
|
|
741
|
+
const serialized = {
|
|
742
|
+
version: '1.0.0',
|
|
743
|
+
receipt,
|
|
744
|
+
quads: quads.map(q => ({
|
|
745
|
+
subject: q.subject.value,
|
|
746
|
+
predicate: q.predicate.value,
|
|
747
|
+
object: q.object.value,
|
|
748
|
+
})),
|
|
749
|
+
};
|
|
750
|
+
return JSON.stringify(serialized, null, 2);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Deserialize snapshot from JSON
|
|
755
|
+
*
|
|
756
|
+
* @param {string} json
|
|
757
|
+
* @returns {{snapshotStore: Store, receipt: Object}}
|
|
758
|
+
*/
|
|
759
|
+
export function deserializeSnapshot(json) {
|
|
760
|
+
const data = JSON.parse(json);
|
|
761
|
+
const store = createStore();
|
|
762
|
+
|
|
763
|
+
for (const q of data.quads) {
|
|
764
|
+
store.addQuad(
|
|
765
|
+
namedNode(q.subject),
|
|
766
|
+
namedNode(q.predicate),
|
|
767
|
+
q.object.startsWith('http') ? namedNode(q.object) : literal(q.object)
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return {
|
|
772
|
+
snapshotStore: store,
|
|
773
|
+
receipt: data.receipt,
|
|
774
|
+
};
|
|
775
|
+
}
|