@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,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Policy derivation from observed project patterns
|
|
3
|
+
* @module project-engine/policy-derivation
|
|
4
|
+
*
|
|
5
|
+
* @description
|
|
6
|
+
* Automatically derives transaction hook policies from observed project patterns.
|
|
7
|
+
* Analyzes the project store and stack profile to detect invariant violations
|
|
8
|
+
* and generates hooks that enforce these patterns on future transactions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { UnrdfDataFactory as DataFactory } from '@unrdf/core/rdf/n3-justified-only';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
|
|
14
|
+
const { namedNode } = DataFactory;
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Zod Schemas
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const StackProfileSchema = z.object({
|
|
21
|
+
uiFramework: z.string().nullable().optional(),
|
|
22
|
+
webFramework: z.string().nullable().optional(),
|
|
23
|
+
apiFramework: z.string().nullable().optional(),
|
|
24
|
+
testFramework: z.string().nullable().optional(),
|
|
25
|
+
packageManager: z.string().nullable().optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const DeriveOptionsSchema = z.object({
|
|
29
|
+
enableFeatureViewPolicy: z.boolean().default(true),
|
|
30
|
+
enableApiTestPolicy: z.boolean().default(true),
|
|
31
|
+
enableOrphanFilePolicy: z.boolean().default(true),
|
|
32
|
+
enableTestCompanionPolicy: z.boolean().default(true),
|
|
33
|
+
enableStackPolicies: z.boolean().default(true),
|
|
34
|
+
strictMode: z.boolean().default(false),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const HookConditionSchema = z.object({
|
|
38
|
+
kind: z.enum(['sparql-ask', 'sparql-select', 'shacl', 'delta', 'threshold', 'count']),
|
|
39
|
+
ref: z
|
|
40
|
+
.object({
|
|
41
|
+
uri: z.string(),
|
|
42
|
+
sha256: z.string().optional(),
|
|
43
|
+
mediaType: z.string().optional(),
|
|
44
|
+
})
|
|
45
|
+
.optional(),
|
|
46
|
+
query: z.string().optional(),
|
|
47
|
+
spec: z.any().optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} DerivedHook
|
|
52
|
+
* @property {Object} meta - Hook metadata
|
|
53
|
+
* @property {string} meta.name - Unique hook name
|
|
54
|
+
* @property {string} [meta.description] - Hook description
|
|
55
|
+
* @property {string[]} [meta.ontology] - Related ontologies
|
|
56
|
+
* @property {Object} [channel] - Observation channel
|
|
57
|
+
* @property {Object} when - Trigger condition
|
|
58
|
+
* @property {Function} run - Main execution body
|
|
59
|
+
* @property {Function} [before] - Pre-condition gate
|
|
60
|
+
* @property {Function} [after] - Post-execution step
|
|
61
|
+
* @property {Object} [determinism] - Determinism config
|
|
62
|
+
* @property {Object} [receipt] - Receipt config
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {Object} PatternViolation
|
|
67
|
+
* @property {string} type - Violation type
|
|
68
|
+
* @property {string} subject - Subject IRI
|
|
69
|
+
* @property {string} message - Human-readable message
|
|
70
|
+
* @property {string} [relatedEntity] - Related entity IRI
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Pattern Detection
|
|
75
|
+
// ============================================================================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect features without views
|
|
79
|
+
* @param {Object} store - N3 Store
|
|
80
|
+
* @returns {PatternViolation[]}
|
|
81
|
+
*/
|
|
82
|
+
function detectFeaturesWithoutViews(store) {
|
|
83
|
+
const violations = [];
|
|
84
|
+
const projectNs = 'http://example.org/unrdf/project#';
|
|
85
|
+
const rdfType = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
86
|
+
|
|
87
|
+
// Find all features
|
|
88
|
+
const featureQuads = store.getQuads(null, namedNode(rdfType), namedNode(`${projectNs}Feature`));
|
|
89
|
+
|
|
90
|
+
for (const featureQuad of featureQuads) {
|
|
91
|
+
const featureIri = featureQuad.subject.value;
|
|
92
|
+
|
|
93
|
+
// Check if feature has any file with Component or Page role
|
|
94
|
+
const roleQuads = store.getQuads(
|
|
95
|
+
null,
|
|
96
|
+
namedNode(`${projectNs}belongsToFeature`),
|
|
97
|
+
namedNode(featureIri)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
let hasView = false;
|
|
101
|
+
for (const roleQuad of roleQuads) {
|
|
102
|
+
const fileIri = roleQuad.subject;
|
|
103
|
+
const fileRoles = store.getQuads(fileIri, namedNode(`${projectNs}roleString`), null);
|
|
104
|
+
|
|
105
|
+
for (const fileRole of fileRoles) {
|
|
106
|
+
if (['Component', 'Page'].includes(fileRole.object.value)) {
|
|
107
|
+
hasView = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (hasView) break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!hasView) {
|
|
115
|
+
violations.push({
|
|
116
|
+
type: 'feature-without-view',
|
|
117
|
+
subject: featureIri,
|
|
118
|
+
message: `Feature "${featureIri}" has no Component or Page view`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return violations;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Detect features with API but no test
|
|
128
|
+
* @param {Object} store - N3 Store
|
|
129
|
+
* @returns {PatternViolation[]}
|
|
130
|
+
*/
|
|
131
|
+
function detectApiWithoutTest(store) {
|
|
132
|
+
const violations = [];
|
|
133
|
+
const projectNs = 'http://example.org/unrdf/project#';
|
|
134
|
+
const rdfType = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
135
|
+
|
|
136
|
+
const featureQuads = store.getQuads(null, namedNode(rdfType), namedNode(`${projectNs}Feature`));
|
|
137
|
+
|
|
138
|
+
for (const featureQuad of featureQuads) {
|
|
139
|
+
const featureIri = featureQuad.subject.value;
|
|
140
|
+
|
|
141
|
+
const roleQuads = store.getQuads(
|
|
142
|
+
null,
|
|
143
|
+
namedNode(`${projectNs}belongsToFeature`),
|
|
144
|
+
namedNode(featureIri)
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
let hasApi = false;
|
|
148
|
+
let hasTest = false;
|
|
149
|
+
|
|
150
|
+
for (const roleQuad of roleQuads) {
|
|
151
|
+
const fileIri = roleQuad.subject;
|
|
152
|
+
const fileRoles = store.getQuads(fileIri, namedNode(`${projectNs}roleString`), null);
|
|
153
|
+
|
|
154
|
+
for (const fileRole of fileRoles) {
|
|
155
|
+
if (fileRole.object.value === 'Api') hasApi = true;
|
|
156
|
+
if (fileRole.object.value === 'Test') hasTest = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (hasApi && !hasTest) {
|
|
161
|
+
violations.push({
|
|
162
|
+
type: 'api-without-test',
|
|
163
|
+
subject: featureIri,
|
|
164
|
+
message: `Feature "${featureIri}" has API but no test`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return violations;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Detect orphan files (files under features/* without Feature association)
|
|
174
|
+
* @param {Object} store - N3 Store
|
|
175
|
+
* @returns {PatternViolation[]}
|
|
176
|
+
*/
|
|
177
|
+
function detectOrphanFiles(store) {
|
|
178
|
+
const violations = [];
|
|
179
|
+
const projectNs = 'http://example.org/unrdf/project#';
|
|
180
|
+
const fsNs = 'http://example.org/unrdf/filesystem#';
|
|
181
|
+
|
|
182
|
+
const fileQuads = store.getQuads(null, namedNode(`${fsNs}relativePath`), null);
|
|
183
|
+
|
|
184
|
+
for (const fileQuad of fileQuads) {
|
|
185
|
+
const filePath = fileQuad.object.value;
|
|
186
|
+
const fileIri = fileQuad.subject;
|
|
187
|
+
|
|
188
|
+
// Check if file is under features/ directory
|
|
189
|
+
if (filePath.match(/^src\/features\/[^/]+\//)) {
|
|
190
|
+
// Check if file belongs to a feature
|
|
191
|
+
const belongsQuads = store.getQuads(fileIri, namedNode(`${projectNs}belongsToFeature`), null);
|
|
192
|
+
|
|
193
|
+
if (belongsQuads.length === 0) {
|
|
194
|
+
violations.push({
|
|
195
|
+
type: 'orphan-file',
|
|
196
|
+
subject: fileIri.value,
|
|
197
|
+
message: `File "${filePath}" is under features/ but has no Feature association`,
|
|
198
|
+
relatedEntity: filePath,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return violations;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Detect main files without test companions
|
|
209
|
+
* @param {Object} store - N3 Store
|
|
210
|
+
* @returns {PatternViolation[]}
|
|
211
|
+
*/
|
|
212
|
+
function detectFilesWithoutTests(store) {
|
|
213
|
+
const violations = [];
|
|
214
|
+
const _projectNs = 'http://example.org/unrdf/project#';
|
|
215
|
+
const fsNs = 'http://example.org/unrdf/filesystem#';
|
|
216
|
+
|
|
217
|
+
const fileQuads = store.getQuads(null, namedNode(`${fsNs}relativePath`), null);
|
|
218
|
+
|
|
219
|
+
const filePaths = new Set();
|
|
220
|
+
const testPaths = new Set();
|
|
221
|
+
|
|
222
|
+
for (const fileQuad of fileQuads) {
|
|
223
|
+
const filePath = fileQuad.object.value;
|
|
224
|
+
filePaths.add(filePath);
|
|
225
|
+
|
|
226
|
+
if (filePath.match(/\.(test|spec)\.(tsx?|jsx?|mjs)$/)) {
|
|
227
|
+
testPaths.add(filePath);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const filePath of filePaths) {
|
|
232
|
+
// Skip test files, config, docs
|
|
233
|
+
if (filePath.match(/\.(test|spec|config|d)\.(tsx?|jsx?|mjs)$/)) continue;
|
|
234
|
+
if (filePath.match(/\.(md|json|yaml|yml)$/)) continue;
|
|
235
|
+
if (!filePath.match(/\.(tsx?|jsx?|mjs)$/)) continue;
|
|
236
|
+
|
|
237
|
+
// Only check src files
|
|
238
|
+
if (!filePath.startsWith('src/')) continue;
|
|
239
|
+
|
|
240
|
+
// Check for corresponding test file
|
|
241
|
+
const baseName = filePath.replace(/\.(tsx?|jsx?|mjs)$/, '');
|
|
242
|
+
const possibleTests = [
|
|
243
|
+
`${baseName}.test.ts`,
|
|
244
|
+
`${baseName}.test.tsx`,
|
|
245
|
+
`${baseName}.test.js`,
|
|
246
|
+
`${baseName}.test.jsx`,
|
|
247
|
+
`${baseName}.test.mjs`,
|
|
248
|
+
`${baseName}.spec.ts`,
|
|
249
|
+
`${baseName}.spec.tsx`,
|
|
250
|
+
`${baseName}.spec.js`,
|
|
251
|
+
`${baseName}.spec.jsx`,
|
|
252
|
+
`${baseName}.spec.mjs`,
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
const hasTest = possibleTests.some(t => testPaths.has(t));
|
|
256
|
+
|
|
257
|
+
if (!hasTest) {
|
|
258
|
+
violations.push({
|
|
259
|
+
type: 'file-without-test',
|
|
260
|
+
subject: filePath,
|
|
261
|
+
message: `Source file "${filePath}" has no corresponding test file`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return violations;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Detect Next.js app router violations
|
|
271
|
+
* @param {Object} store - N3 Store
|
|
272
|
+
* @param {Object} stackProfile - Stack profile
|
|
273
|
+
* @returns {PatternViolation[]}
|
|
274
|
+
*/
|
|
275
|
+
function detectNextAppRouterViolations(store, stackProfile) {
|
|
276
|
+
const violations = [];
|
|
277
|
+
|
|
278
|
+
if (stackProfile.webFramework !== 'next-app-router') {
|
|
279
|
+
return violations;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const fsNs = 'http://example.org/unrdf/filesystem#';
|
|
283
|
+
|
|
284
|
+
const fileQuads = store.getQuads(null, namedNode(`${fsNs}relativePath`), null);
|
|
285
|
+
|
|
286
|
+
const filePaths = new Set();
|
|
287
|
+
for (const fileQuad of fileQuads) {
|
|
288
|
+
filePaths.add(fileQuad.object.value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Find page.tsx files and check for route.ts
|
|
292
|
+
for (const filePath of filePaths) {
|
|
293
|
+
if (filePath.match(/app\/.*\/page\.(tsx?|jsx?)$/)) {
|
|
294
|
+
const routeDir = filePath.replace(/page\.(tsx?|jsx?)$/, '');
|
|
295
|
+
const routeFile = `${routeDir}route.ts`;
|
|
296
|
+
const routeFileTsx = `${routeDir}route.tsx`;
|
|
297
|
+
|
|
298
|
+
// Note: route.ts is optional, but if it's an API route it should exist
|
|
299
|
+
// This is a soft check - only flag if directory looks like API route
|
|
300
|
+
if (routeDir.match(/\/api\//)) {
|
|
301
|
+
if (!filePaths.has(routeFile) && !filePaths.has(routeFileTsx)) {
|
|
302
|
+
violations.push({
|
|
303
|
+
type: 'next-app-router-missing-route',
|
|
304
|
+
subject: filePath,
|
|
305
|
+
message: `API route "${routeDir}" has page.tsx but no route.ts`,
|
|
306
|
+
relatedEntity: routeFile,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return violations;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ============================================================================
|
|
317
|
+
// Hook Generation
|
|
318
|
+
// ============================================================================
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create hook for feature-must-have-view invariant
|
|
322
|
+
* @param {PatternViolation[]} violations - Detected violations
|
|
323
|
+
* @returns {DerivedHook}
|
|
324
|
+
*/
|
|
325
|
+
function createFeatureViewHook(_violations) {
|
|
326
|
+
return {
|
|
327
|
+
meta: {
|
|
328
|
+
name: 'derived:feature-must-have-view',
|
|
329
|
+
description: 'Every Feature must have at least one Component or Page view',
|
|
330
|
+
ontology: ['unrdf-project'],
|
|
331
|
+
},
|
|
332
|
+
channel: {
|
|
333
|
+
graphs: ['urn:graph:project'],
|
|
334
|
+
view: 'after',
|
|
335
|
+
},
|
|
336
|
+
when: {
|
|
337
|
+
kind: 'sparql-ask',
|
|
338
|
+
query: `
|
|
339
|
+
PREFIX unproj: <http://example.org/unrdf/project#>
|
|
340
|
+
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
341
|
+
ASK {
|
|
342
|
+
?feature rdf:type unproj:Feature .
|
|
343
|
+
FILTER NOT EXISTS {
|
|
344
|
+
?file unproj:belongsToFeature ?feature ;
|
|
345
|
+
unproj:roleString ?role .
|
|
346
|
+
FILTER(?role IN ("Component", "Page"))
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
`,
|
|
350
|
+
},
|
|
351
|
+
determinism: { seed: 42 },
|
|
352
|
+
receipt: { anchor: 'none' },
|
|
353
|
+
|
|
354
|
+
before({ payload }) {
|
|
355
|
+
return payload;
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
run({ _payload, context }) {
|
|
359
|
+
const store = context.graph;
|
|
360
|
+
if (!store) {
|
|
361
|
+
return { result: { valid: true, violations: [] } };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const projectNs = 'http://example.org/unrdf/project#';
|
|
365
|
+
const rdfType = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type';
|
|
366
|
+
|
|
367
|
+
const currentViolations = [];
|
|
368
|
+
const featureQuads = store.getQuads(
|
|
369
|
+
null,
|
|
370
|
+
namedNode(rdfType),
|
|
371
|
+
namedNode(`${projectNs}Feature`)
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
for (const featureQuad of featureQuads) {
|
|
375
|
+
const featureIri = featureQuad.subject.value;
|
|
376
|
+
const roleQuads = store.getQuads(
|
|
377
|
+
null,
|
|
378
|
+
namedNode(`${projectNs}belongsToFeature`),
|
|
379
|
+
namedNode(featureIri)
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
let hasView = false;
|
|
383
|
+
for (const roleQuad of roleQuads) {
|
|
384
|
+
const fileRoles = store.getQuads(
|
|
385
|
+
roleQuad.subject,
|
|
386
|
+
namedNode(`${projectNs}roleString`),
|
|
387
|
+
null
|
|
388
|
+
);
|
|
389
|
+
for (const fileRole of fileRoles) {
|
|
390
|
+
if (['Component', 'Page'].includes(fileRole.object.value)) {
|
|
391
|
+
hasView = true;
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (hasView) break;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!hasView) {
|
|
399
|
+
currentViolations.push({
|
|
400
|
+
type: 'feature-without-view',
|
|
401
|
+
subject: featureIri,
|
|
402
|
+
message: `Feature "${featureIri}" must have at least one view`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
result: {
|
|
409
|
+
valid: currentViolations.length === 0,
|
|
410
|
+
violations: currentViolations,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
after({ result, cancelled, reason }) {
|
|
416
|
+
if (cancelled) {
|
|
417
|
+
return { result: { status: 'cancelled', reason } };
|
|
418
|
+
}
|
|
419
|
+
return { result: { status: 'completed', ...result } };
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Create hook for api-must-have-test invariant
|
|
426
|
+
* @param {PatternViolation[]} violations - Detected violations
|
|
427
|
+
* @returns {DerivedHook}
|
|
428
|
+
*/
|
|
429
|
+
function createApiTestHook(_violations) {
|
|
430
|
+
return {
|
|
431
|
+
meta: {
|
|
432
|
+
name: 'derived:api-must-have-test',
|
|
433
|
+
description: 'Every Feature with API must have Test',
|
|
434
|
+
ontology: ['unrdf-project'],
|
|
435
|
+
},
|
|
436
|
+
channel: {
|
|
437
|
+
graphs: ['urn:graph:project'],
|
|
438
|
+
view: 'after',
|
|
439
|
+
},
|
|
440
|
+
when: {
|
|
441
|
+
kind: 'sparql-ask',
|
|
442
|
+
query: `
|
|
443
|
+
PREFIX unproj: <http://example.org/unrdf/project#>
|
|
444
|
+
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
445
|
+
ASK {
|
|
446
|
+
?feature rdf:type unproj:Feature .
|
|
447
|
+
?apiFile unproj:belongsToFeature ?feature ;
|
|
448
|
+
unproj:roleString "Api" .
|
|
449
|
+
FILTER NOT EXISTS {
|
|
450
|
+
?testFile unproj:belongsToFeature ?feature ;
|
|
451
|
+
unproj:roleString "Test" .
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
`,
|
|
455
|
+
},
|
|
456
|
+
determinism: { seed: 42 },
|
|
457
|
+
receipt: { anchor: 'none' },
|
|
458
|
+
|
|
459
|
+
before({ payload }) {
|
|
460
|
+
return payload;
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
run({ _payload, context }) {
|
|
464
|
+
const store = context.graph;
|
|
465
|
+
if (!store) {
|
|
466
|
+
return { result: { valid: true, violations: [] } };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const currentViolations = detectApiWithoutTest(store);
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
result: {
|
|
473
|
+
valid: currentViolations.length === 0,
|
|
474
|
+
violations: currentViolations,
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
after({ result, cancelled, reason }) {
|
|
480
|
+
if (cancelled) {
|
|
481
|
+
return { result: { status: 'cancelled', reason } };
|
|
482
|
+
}
|
|
483
|
+
return { result: { status: 'completed', ...result } };
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Create hook for orphan-file invariant
|
|
490
|
+
* @param {PatternViolation[]} violations - Detected violations
|
|
491
|
+
* @returns {DerivedHook}
|
|
492
|
+
*/
|
|
493
|
+
function createOrphanFileHook(_violations) {
|
|
494
|
+
return {
|
|
495
|
+
meta: {
|
|
496
|
+
name: 'derived:no-orphan-files',
|
|
497
|
+
description: 'Files under features/* must belong to some Feature',
|
|
498
|
+
ontology: ['unrdf-project', 'unrdf-filesystem'],
|
|
499
|
+
},
|
|
500
|
+
channel: {
|
|
501
|
+
graphs: ['urn:graph:project', 'urn:graph:fs'],
|
|
502
|
+
view: 'after',
|
|
503
|
+
},
|
|
504
|
+
when: {
|
|
505
|
+
kind: 'sparql-ask',
|
|
506
|
+
query: `
|
|
507
|
+
PREFIX unproj: <http://example.org/unrdf/project#>
|
|
508
|
+
PREFIX fs: <http://example.org/unrdf/filesystem#>
|
|
509
|
+
ASK {
|
|
510
|
+
?file fs:relativePath ?path .
|
|
511
|
+
FILTER(REGEX(?path, "^src/features/[^/]+/"))
|
|
512
|
+
FILTER NOT EXISTS {
|
|
513
|
+
?file unproj:belongsToFeature ?feature .
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
`,
|
|
517
|
+
},
|
|
518
|
+
determinism: { seed: 42 },
|
|
519
|
+
receipt: { anchor: 'none' },
|
|
520
|
+
|
|
521
|
+
before({ payload }) {
|
|
522
|
+
return payload;
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
run({ _payload, context }) {
|
|
526
|
+
const store = context.graph;
|
|
527
|
+
if (!store) {
|
|
528
|
+
return { result: { valid: true, violations: [] } };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const currentViolations = detectOrphanFiles(store);
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
result: {
|
|
535
|
+
valid: currentViolations.length === 0,
|
|
536
|
+
violations: currentViolations,
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
after({ result, cancelled, reason }) {
|
|
542
|
+
if (cancelled) {
|
|
543
|
+
return { result: { status: 'cancelled', reason } };
|
|
544
|
+
}
|
|
545
|
+
return { result: { status: 'completed', ...result } };
|
|
546
|
+
},
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Create hook for test-companion invariant
|
|
552
|
+
* @param {PatternViolation[]} violations - Detected violations
|
|
553
|
+
* @returns {DerivedHook}
|
|
554
|
+
*/
|
|
555
|
+
function createTestCompanionHook(_violations) {
|
|
556
|
+
return {
|
|
557
|
+
meta: {
|
|
558
|
+
name: 'derived:test-companion-required',
|
|
559
|
+
description: 'Test file must accompany main file',
|
|
560
|
+
ontology: ['unrdf-project', 'unrdf-filesystem'],
|
|
561
|
+
},
|
|
562
|
+
channel: {
|
|
563
|
+
graphs: ['urn:graph:project', 'urn:graph:fs'],
|
|
564
|
+
view: 'after',
|
|
565
|
+
},
|
|
566
|
+
when: {
|
|
567
|
+
kind: 'count',
|
|
568
|
+
spec: {
|
|
569
|
+
op: '>',
|
|
570
|
+
value: 0,
|
|
571
|
+
query:
|
|
572
|
+
'SELECT (COUNT(?file) AS ?count) WHERE { ?file a <http://example.org/unrdf/filesystem#File> }',
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
determinism: { seed: 42 },
|
|
576
|
+
receipt: { anchor: 'none' },
|
|
577
|
+
|
|
578
|
+
before({ payload }) {
|
|
579
|
+
return payload;
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
run({ _payload, context }) {
|
|
583
|
+
const store = context.graph;
|
|
584
|
+
if (!store) {
|
|
585
|
+
return { result: { valid: true, violations: [] } };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const currentViolations = detectFilesWithoutTests(store);
|
|
589
|
+
|
|
590
|
+
return {
|
|
591
|
+
result: {
|
|
592
|
+
valid: currentViolations.length === 0,
|
|
593
|
+
violations: currentViolations,
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
after({ result, cancelled, reason }) {
|
|
599
|
+
if (cancelled) {
|
|
600
|
+
return { result: { status: 'cancelled', reason } };
|
|
601
|
+
}
|
|
602
|
+
return { result: { status: 'completed', ...result } };
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Create hook for Next.js app router structure
|
|
609
|
+
* @param {PatternViolation[]} violations - Detected violations
|
|
610
|
+
* @param {Object} stackProfile - Stack profile
|
|
611
|
+
* @returns {DerivedHook}
|
|
612
|
+
*/
|
|
613
|
+
function createNextAppRouterHook(violations, stackProfile) {
|
|
614
|
+
return {
|
|
615
|
+
meta: {
|
|
616
|
+
name: 'derived:next-app-router-structure',
|
|
617
|
+
description: 'Next.js app/[route]/page.tsx must have route.ts for API routes',
|
|
618
|
+
ontology: ['unrdf-project', 'next-app-router'],
|
|
619
|
+
},
|
|
620
|
+
channel: {
|
|
621
|
+
graphs: ['urn:graph:project', 'urn:graph:fs'],
|
|
622
|
+
view: 'after',
|
|
623
|
+
},
|
|
624
|
+
when: {
|
|
625
|
+
kind: 'sparql-ask',
|
|
626
|
+
query: `
|
|
627
|
+
PREFIX fs: <http://example.org/unrdf/filesystem#>
|
|
628
|
+
ASK {
|
|
629
|
+
?file fs:relativePath ?path .
|
|
630
|
+
FILTER(REGEX(?path, "app/.*/api/.*/page\\\\.(tsx?|jsx?)$"))
|
|
631
|
+
}
|
|
632
|
+
`,
|
|
633
|
+
},
|
|
634
|
+
determinism: { seed: 42 },
|
|
635
|
+
receipt: { anchor: 'none' },
|
|
636
|
+
|
|
637
|
+
before({ payload }) {
|
|
638
|
+
return payload;
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
run({ _payload, context }) {
|
|
642
|
+
const store = context.graph;
|
|
643
|
+
if (!store) {
|
|
644
|
+
return { result: { valid: true, violations: [] } };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const currentViolations = detectNextAppRouterViolations(store, stackProfile);
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
result: {
|
|
651
|
+
valid: currentViolations.length === 0,
|
|
652
|
+
violations: currentViolations,
|
|
653
|
+
stackProfile,
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
},
|
|
657
|
+
|
|
658
|
+
after({ result, cancelled, reason }) {
|
|
659
|
+
if (cancelled) {
|
|
660
|
+
return { result: { status: 'cancelled', reason } };
|
|
661
|
+
}
|
|
662
|
+
return { result: { status: 'completed', ...result } };
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ============================================================================
|
|
668
|
+
// Main API
|
|
669
|
+
// ============================================================================
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Derive transaction hook policies from observed project patterns
|
|
673
|
+
*
|
|
674
|
+
* Analyzes the project store to detect pattern violations and generates
|
|
675
|
+
* hooks that enforce these patterns on future transactions.
|
|
676
|
+
*
|
|
677
|
+
* @param {Object} projectStore - N3 Store with project structure
|
|
678
|
+
* @param {Object} stackProfile - Stack profile from detectStackFromFs
|
|
679
|
+
* @param {Object} [options] - Derivation options
|
|
680
|
+
* @param {boolean} [options.enableFeatureViewPolicy=true] - Enable feature-view policy
|
|
681
|
+
* @param {boolean} [options.enableApiTestPolicy=true] - Enable api-test policy
|
|
682
|
+
* @param {boolean} [options.enableOrphanFilePolicy=true] - Enable orphan-file policy
|
|
683
|
+
* @param {boolean} [options.enableTestCompanionPolicy=true] - Enable test-companion policy
|
|
684
|
+
* @param {boolean} [options.enableStackPolicies=true] - Enable stack-specific policies
|
|
685
|
+
* @param {boolean} [options.strictMode=false] - Fail on validation errors
|
|
686
|
+
* @returns {DerivedHook[]} Array of Hook objects ready for KnowledgeHookManager.registerHook()
|
|
687
|
+
*
|
|
688
|
+
* @example
|
|
689
|
+
* import { deriveHooksFromStructure } from './policy-derivation.mjs'
|
|
690
|
+
* import { KnowledgeHookManager } from '../knowledge-engine/knowledge-hook-manager.mjs'
|
|
691
|
+
*
|
|
692
|
+
* const hooks = deriveHooksFromStructure(projectStore, stackProfile)
|
|
693
|
+
* const manager = new KnowledgeHookManager()
|
|
694
|
+
*
|
|
695
|
+
* for (const hook of hooks) {
|
|
696
|
+
* manager.addKnowledgeHook(hook)
|
|
697
|
+
* }
|
|
698
|
+
*/
|
|
699
|
+
export function deriveHooksFromStructure(projectStore, stackProfile, options = {}) {
|
|
700
|
+
// Validate inputs
|
|
701
|
+
if (!projectStore || typeof projectStore.getQuads !== 'function') {
|
|
702
|
+
throw new TypeError('deriveHooksFromStructure: projectStore must be an N3 Store');
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const validatedStack = StackProfileSchema.parse(stackProfile || {});
|
|
706
|
+
const validatedOptions = DeriveOptionsSchema.parse(options);
|
|
707
|
+
|
|
708
|
+
const hooks = [];
|
|
709
|
+
|
|
710
|
+
// Detect violations for each pattern type
|
|
711
|
+
if (validatedOptions.enableFeatureViewPolicy) {
|
|
712
|
+
const featureViewViolations = detectFeaturesWithoutViews(projectStore);
|
|
713
|
+
hooks.push(createFeatureViewHook(featureViewViolations));
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
if (validatedOptions.enableApiTestPolicy) {
|
|
717
|
+
const apiTestViolations = detectApiWithoutTest(projectStore);
|
|
718
|
+
hooks.push(createApiTestHook(apiTestViolations));
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (validatedOptions.enableOrphanFilePolicy) {
|
|
722
|
+
const orphanViolations = detectOrphanFiles(projectStore);
|
|
723
|
+
hooks.push(createOrphanFileHook(orphanViolations));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (validatedOptions.enableTestCompanionPolicy) {
|
|
727
|
+
const testCompanionViolations = detectFilesWithoutTests(projectStore);
|
|
728
|
+
hooks.push(createTestCompanionHook(testCompanionViolations));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Stack-specific policies
|
|
732
|
+
if (validatedOptions.enableStackPolicies) {
|
|
733
|
+
if (validatedStack.webFramework === 'next-app-router') {
|
|
734
|
+
const nextViolations = detectNextAppRouterViolations(projectStore, validatedStack);
|
|
735
|
+
hooks.push(createNextAppRouterHook(nextViolations, validatedStack));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return hooks;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Analyze project store and return violation report without generating hooks
|
|
744
|
+
*
|
|
745
|
+
* @param {Object} projectStore - N3 Store with project structure
|
|
746
|
+
* @param {Object} stackProfile - Stack profile from detectStackFromFs
|
|
747
|
+
* @param {Object} [options] - Analysis options
|
|
748
|
+
* @returns {Object} Violation report
|
|
749
|
+
*/
|
|
750
|
+
export function analyzePatternViolations(projectStore, stackProfile, options = {}) {
|
|
751
|
+
if (!projectStore || typeof projectStore.getQuads !== 'function') {
|
|
752
|
+
throw new TypeError('analyzePatternViolations: projectStore must be an N3 Store');
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const validatedStack = StackProfileSchema.parse(stackProfile || {});
|
|
756
|
+
const validatedOptions = DeriveOptionsSchema.parse(options);
|
|
757
|
+
|
|
758
|
+
const report = {
|
|
759
|
+
timestamp: new Date().toISOString(),
|
|
760
|
+
stackProfile: validatedStack,
|
|
761
|
+
violations: {
|
|
762
|
+
featureWithoutView: [],
|
|
763
|
+
apiWithoutTest: [],
|
|
764
|
+
orphanFiles: [],
|
|
765
|
+
filesWithoutTests: [],
|
|
766
|
+
stackSpecific: [],
|
|
767
|
+
},
|
|
768
|
+
summary: {
|
|
769
|
+
total: 0,
|
|
770
|
+
byType: {},
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
if (validatedOptions.enableFeatureViewPolicy) {
|
|
775
|
+
report.violations.featureWithoutView = detectFeaturesWithoutViews(projectStore);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (validatedOptions.enableApiTestPolicy) {
|
|
779
|
+
report.violations.apiWithoutTest = detectApiWithoutTest(projectStore);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (validatedOptions.enableOrphanFilePolicy) {
|
|
783
|
+
report.violations.orphanFiles = detectOrphanFiles(projectStore);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (validatedOptions.enableTestCompanionPolicy) {
|
|
787
|
+
report.violations.filesWithoutTests = detectFilesWithoutTests(projectStore);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (validatedOptions.enableStackPolicies) {
|
|
791
|
+
if (validatedStack.webFramework === 'next-app-router') {
|
|
792
|
+
report.violations.stackSpecific = detectNextAppRouterViolations(projectStore, validatedStack);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Compute summary
|
|
797
|
+
for (const [type, violations] of Object.entries(report.violations)) {
|
|
798
|
+
report.summary.byType[type] = violations.length;
|
|
799
|
+
report.summary.total += violations.length;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return report;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Create a custom hook from a pattern specification
|
|
807
|
+
*
|
|
808
|
+
* @param {Object} spec - Pattern specification
|
|
809
|
+
* @param {string} spec.name - Hook name
|
|
810
|
+
* @param {string} spec.description - Hook description
|
|
811
|
+
* @param {string} spec.pattern - Pattern type
|
|
812
|
+
* @param {Object} spec.condition - Trigger condition
|
|
813
|
+
* @param {Function} spec.validator - Validation function
|
|
814
|
+
* @returns {DerivedHook} Hook object
|
|
815
|
+
*/
|
|
816
|
+
export function createCustomPatternHook(spec) {
|
|
817
|
+
const SpecSchema = z.object({
|
|
818
|
+
name: z.string().min(1).max(100),
|
|
819
|
+
description: z.string().min(1).max(500),
|
|
820
|
+
pattern: z.string().min(1),
|
|
821
|
+
condition: HookConditionSchema,
|
|
822
|
+
validator: z.function(),
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
const validated = SpecSchema.parse(spec);
|
|
826
|
+
|
|
827
|
+
return {
|
|
828
|
+
meta: {
|
|
829
|
+
name: `custom:${validated.name}`,
|
|
830
|
+
description: validated.description,
|
|
831
|
+
ontology: ['unrdf-project'],
|
|
832
|
+
},
|
|
833
|
+
channel: {
|
|
834
|
+
graphs: ['urn:graph:project'],
|
|
835
|
+
view: 'after',
|
|
836
|
+
},
|
|
837
|
+
when: validated.condition,
|
|
838
|
+
determinism: { seed: 42 },
|
|
839
|
+
receipt: { anchor: 'none' },
|
|
840
|
+
|
|
841
|
+
before({ payload }) {
|
|
842
|
+
return payload;
|
|
843
|
+
},
|
|
844
|
+
|
|
845
|
+
run({ payload, context }) {
|
|
846
|
+
const store = context.graph;
|
|
847
|
+
if (!store) {
|
|
848
|
+
return { result: { valid: true, violations: [] } };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const violations = validated.validator(store, payload);
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
result: {
|
|
855
|
+
valid: Array.isArray(violations) ? violations.length === 0 : violations,
|
|
856
|
+
violations: Array.isArray(violations) ? violations : [],
|
|
857
|
+
pattern: validated.pattern,
|
|
858
|
+
},
|
|
859
|
+
};
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
after({ result, cancelled, reason }) {
|
|
863
|
+
if (cancelled) {
|
|
864
|
+
return { result: { status: 'cancelled', reason } };
|
|
865
|
+
}
|
|
866
|
+
return { result: { status: 'completed', ...result } };
|
|
867
|
+
},
|
|
868
|
+
};
|
|
869
|
+
}
|