@neurcode-ai/governance-runtime 0.1.2 → 0.1.3
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/dist/constraints.d.ts +9 -1
- package/dist/constraints.d.ts.map +1 -1
- package/dist/constraints.js +285 -34
- package/dist/constraints.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +85 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/constraints.ts +450 -54
- package/src/index.test.ts +120 -0
- package/src/index.ts +107 -17
package/src/index.test.ts
CHANGED
|
@@ -335,6 +335,89 @@ test('compileDeterministicConstraints supports exact invocation constraints', ()
|
|
|
335
335
|
assert.equal(rule?.maxMatchesPerFile, 3);
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
test('compileDeterministicConstraints supports repo-scoped invocation constraints', () => {
|
|
339
|
+
const compiled = compileDeterministicConstraints({
|
|
340
|
+
intentConstraints: 'foo should be called at most 2 times across repository',
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const rule = compiled.rules.find((item) => item.id.includes('invocations_foo_repo'));
|
|
344
|
+
assert.ok(rule, 'expected repo-scoped invocation rule');
|
|
345
|
+
assert.equal(rule?.evaluationScope, 'repo');
|
|
346
|
+
assert.equal(rule?.maxMatchesPerFile, 2);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('evaluatePlanVerification enforces repo-scoped invocation constraints', () => {
|
|
350
|
+
const result = evaluatePlanVerification({
|
|
351
|
+
planFiles: SAMPLE_PLAN,
|
|
352
|
+
changedFiles: [
|
|
353
|
+
{
|
|
354
|
+
path: 'src/a.ts',
|
|
355
|
+
changeType: 'modify',
|
|
356
|
+
added: 1,
|
|
357
|
+
removed: 0,
|
|
358
|
+
hunks: [
|
|
359
|
+
{
|
|
360
|
+
oldStart: 1,
|
|
361
|
+
oldLines: 0,
|
|
362
|
+
newStart: 1,
|
|
363
|
+
newLines: 1,
|
|
364
|
+
lines: [
|
|
365
|
+
{
|
|
366
|
+
type: 'added',
|
|
367
|
+
content: 'const marker = true;',
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
intentConstraints: 'foo called at most 1 times across repository',
|
|
375
|
+
fileContents: {
|
|
376
|
+
'src/a.ts': 'foo();\n',
|
|
377
|
+
'src/b.ts': 'foo();\n',
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
assert.equal(result.verdict, 'FAIL');
|
|
382
|
+
assert.ok(result.constraintViolations.some((item) => /across repository scope/i.test(item)));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('evaluatePlanVerification detects exported API signature drift constraints', () => {
|
|
386
|
+
const result = evaluatePlanVerification({
|
|
387
|
+
planFiles: SAMPLE_PLAN,
|
|
388
|
+
changedFiles: [
|
|
389
|
+
{
|
|
390
|
+
path: 'src/api.ts',
|
|
391
|
+
changeType: 'modify',
|
|
392
|
+
added: 1,
|
|
393
|
+
removed: 1,
|
|
394
|
+
hunks: [
|
|
395
|
+
{
|
|
396
|
+
oldStart: 1,
|
|
397
|
+
oldLines: 1,
|
|
398
|
+
newStart: 1,
|
|
399
|
+
newLines: 1,
|
|
400
|
+
lines: [
|
|
401
|
+
{
|
|
402
|
+
type: 'removed',
|
|
403
|
+
content: 'export function createUser(name: string): User {',
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
type: 'added',
|
|
407
|
+
content: 'export function createUser(name: string, email: string): User {',
|
|
408
|
+
},
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
intentConstraints: 'Do not change public API signatures',
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
assert.equal(result.verdict, 'FAIL');
|
|
418
|
+
assert.ok(result.constraintViolations.some((item) => /signature delta/i.test(item)));
|
|
419
|
+
});
|
|
420
|
+
|
|
338
421
|
test('evaluatePlanVerification detects network-call invariants deterministically', () => {
|
|
339
422
|
const result = evaluatePlanVerification({
|
|
340
423
|
planFiles: SAMPLE_PLAN,
|
|
@@ -368,6 +451,43 @@ test('evaluatePlanVerification detects network-call invariants deterministically
|
|
|
368
451
|
assert.match(result.constraintViolations[0], /network-call/i);
|
|
369
452
|
});
|
|
370
453
|
|
|
454
|
+
test('compileDeterministicConstraints expands advanced architectural invariants', () => {
|
|
455
|
+
const compiled = compileDeterministicConstraints({
|
|
456
|
+
intentConstraints: [
|
|
457
|
+
'Preserve backward compatibility for public endpoints',
|
|
458
|
+
'Maintain async event ordering and do not process messages out of order',
|
|
459
|
+
'Do not remove required fields from event payload schema',
|
|
460
|
+
'Enforce multi-tenant isolation and avoid cross-tenant access',
|
|
461
|
+
'Do not invalidate global cache keys',
|
|
462
|
+
'Preserve idempotency guarantees for retryable writes',
|
|
463
|
+
'Avoid destructive schema migrations such as DROP COLUMN',
|
|
464
|
+
].join('; '),
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const ruleIds = compiled.rules.map((rule) => rule.id);
|
|
468
|
+
assert.ok(ruleIds.includes('intent:backward_compatibility'));
|
|
469
|
+
assert.ok(ruleIds.includes('intent:async_ordering'));
|
|
470
|
+
assert.ok(ruleIds.includes('intent:event_schema_consistency'));
|
|
471
|
+
assert.ok(ruleIds.includes('intent:multi_tenant_isolation'));
|
|
472
|
+
assert.ok(ruleIds.includes('intent:cache_invariant'));
|
|
473
|
+
assert.ok(ruleIds.includes('intent:idempotency'));
|
|
474
|
+
assert.ok(ruleIds.includes('intent:migration_safety'));
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('compileDeterministicConstraints attaches provenance metadata', () => {
|
|
478
|
+
const compiled = compileDeterministicConstraints({
|
|
479
|
+
intentConstraints: 'Preserve backward compatibility for public endpoints in src/api/**',
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const rule = compiled.rules.find((item) => item.id === 'intent:backward_compatibility');
|
|
483
|
+
assert.ok(rule, 'expected backward compatibility rule');
|
|
484
|
+
assert.ok(rule?.provenance, 'expected provenance metadata');
|
|
485
|
+
assert.equal(rule?.provenance?.why.length ? true : false, true);
|
|
486
|
+
assert.ok((rule?.provenance?.evidence || []).length > 0);
|
|
487
|
+
assert.ok((rule?.provenance?.trustBoundaries || []).includes('public-api'));
|
|
488
|
+
assert.ok((rule?.provenance?.contributingGraphPaths || []).includes('src/api/**'));
|
|
489
|
+
});
|
|
490
|
+
|
|
371
491
|
test('buildPlanVerificationMessage handles incomplete 0/0 plans', () => {
|
|
372
492
|
const message = buildPlanVerificationMessage({
|
|
373
493
|
constraintViolations: [],
|
package/src/index.ts
CHANGED
|
@@ -125,6 +125,10 @@ function detectConstraintViolations(
|
|
|
125
125
|
|
|
126
126
|
const violations: string[] = [];
|
|
127
127
|
const seenViolations = new Set<string>();
|
|
128
|
+
const normalizedFileContents: Record<string, string> = {};
|
|
129
|
+
for (const [path, content] of Object.entries(fileContents || {})) {
|
|
130
|
+
normalizedFileContents[normalizeRepoPath(path)] = content;
|
|
131
|
+
}
|
|
128
132
|
|
|
129
133
|
const pathMatchesRule = (rule: DeterministicConstraintRule, filePath: string): boolean => {
|
|
130
134
|
const include = rule.pathIncludes || [];
|
|
@@ -149,7 +153,106 @@ function detectConstraintViolations(
|
|
|
149
153
|
return total;
|
|
150
154
|
};
|
|
151
155
|
|
|
156
|
+
const addedLinesByPath = new Map<string, string>();
|
|
157
|
+
for (const file of changedFiles) {
|
|
158
|
+
const addedLines = (file.hunks || []).flatMap((hunk) =>
|
|
159
|
+
hunk.lines
|
|
160
|
+
.filter((line) => line.type === 'added')
|
|
161
|
+
.map((line) => line.content)
|
|
162
|
+
);
|
|
163
|
+
addedLinesByPath.set(file.path, addedLines.join('\n'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const candidateRepoPaths = new Set<string>([
|
|
167
|
+
...changedFiles.map((file) => file.path),
|
|
168
|
+
...Object.keys(normalizedFileContents),
|
|
169
|
+
]);
|
|
170
|
+
|
|
152
171
|
for (const rule of rules) {
|
|
172
|
+
if (rule.evaluationMode === 'signature_delta') {
|
|
173
|
+
for (const file of changedFiles) {
|
|
174
|
+
if (!pathMatchesRule(rule, file.path)) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const addedSignatureLines = (file.hunks || []).flatMap((hunk) =>
|
|
178
|
+
hunk.lines
|
|
179
|
+
.filter((line) => line.type === 'added' && new RegExp(rule.pattern.source, rule.pattern.flags).test(line.content))
|
|
180
|
+
.map((line) => line.content)
|
|
181
|
+
);
|
|
182
|
+
const removedSignatureLines = (file.hunks || []).flatMap((hunk) =>
|
|
183
|
+
hunk.lines
|
|
184
|
+
.filter((line) => line.type === 'removed' && new RegExp(rule.pattern.source, rule.pattern.flags).test(line.content))
|
|
185
|
+
.map((line) => line.content)
|
|
186
|
+
);
|
|
187
|
+
const deltaCount = addedSignatureLines.length + removedSignatureLines.length;
|
|
188
|
+
if (deltaCount === 0) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const violation =
|
|
192
|
+
`Exported API signature delta detected in ${file.path} ` +
|
|
193
|
+
`(added ${addedSignatureLines.length}, removed ${removedSignatureLines.length}, violates constraint: "${rule.displayName}")`;
|
|
194
|
+
if (!seenViolations.has(violation)) {
|
|
195
|
+
seenViolations.add(violation);
|
|
196
|
+
violations.push(violation);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const hasMatchBounds =
|
|
203
|
+
(typeof rule.maxMatchesPerFile === 'number' && Number.isFinite(rule.maxMatchesPerFile))
|
|
204
|
+
|| (typeof rule.minMatchesPerFile === 'number' && Number.isFinite(rule.minMatchesPerFile));
|
|
205
|
+
|
|
206
|
+
if (rule.evaluationScope === 'repo' && hasMatchBounds) {
|
|
207
|
+
let repoMatchCount = 0;
|
|
208
|
+
let scannedPaths = 0;
|
|
209
|
+
for (const filePath of candidateRepoPaths) {
|
|
210
|
+
if (!pathMatchesRule(rule, filePath)) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const fullContent = normalizedFileContents[filePath];
|
|
214
|
+
const addedFallback = addedLinesByPath.get(filePath) || '';
|
|
215
|
+
const haystack =
|
|
216
|
+
rule.evaluationMode === 'full_file' && typeof fullContent === 'string'
|
|
217
|
+
? fullContent
|
|
218
|
+
: addedFallback;
|
|
219
|
+
if (!haystack) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
scannedPaths += 1;
|
|
223
|
+
repoMatchCount += countMatches(rule.pattern, haystack);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
typeof rule.maxMatchesPerFile === 'number'
|
|
228
|
+
&& Number.isFinite(rule.maxMatchesPerFile)
|
|
229
|
+
&& repoMatchCount > rule.maxMatchesPerFile
|
|
230
|
+
) {
|
|
231
|
+
const violation =
|
|
232
|
+
`${rule.matchToken} matched ${repoMatchCount} times across repository scope ` +
|
|
233
|
+
`(limit ${rule.maxMatchesPerFile}, scanned ${scannedPaths} file(s), violates constraint: "${rule.displayName}")`;
|
|
234
|
+
if (!seenViolations.has(violation)) {
|
|
235
|
+
seenViolations.add(violation);
|
|
236
|
+
violations.push(violation);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (
|
|
241
|
+
typeof rule.minMatchesPerFile === 'number'
|
|
242
|
+
&& Number.isFinite(rule.minMatchesPerFile)
|
|
243
|
+
&& repoMatchCount < rule.minMatchesPerFile
|
|
244
|
+
) {
|
|
245
|
+
const violation =
|
|
246
|
+
`${rule.matchToken} matched ${repoMatchCount} times across repository scope ` +
|
|
247
|
+
`(minimum ${rule.minMatchesPerFile}, scanned ${scannedPaths} file(s), violates constraint: "${rule.displayName}")`;
|
|
248
|
+
if (!seenViolations.has(violation)) {
|
|
249
|
+
seenViolations.add(violation);
|
|
250
|
+
violations.push(violation);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
153
256
|
for (const file of changedFiles) {
|
|
154
257
|
if (!pathMatchesRule(rule, file.path)) {
|
|
155
258
|
continue;
|
|
@@ -158,27 +261,14 @@ function detectConstraintViolations(
|
|
|
158
261
|
continue;
|
|
159
262
|
}
|
|
160
263
|
|
|
161
|
-
const addedLines = file.
|
|
162
|
-
hunk.lines
|
|
163
|
-
.filter((line) => line.type === 'added')
|
|
164
|
-
.map((line) => line.content)
|
|
165
|
-
);
|
|
264
|
+
const addedLines = addedLinesByPath.get(file.path) || '';
|
|
166
265
|
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
typeof rule.maxMatchesPerFile === 'number'
|
|
170
|
-
&& Number.isFinite(rule.maxMatchesPerFile)
|
|
171
|
-
) || (
|
|
172
|
-
typeof rule.minMatchesPerFile === 'number'
|
|
173
|
-
&& Number.isFinite(rule.minMatchesPerFile)
|
|
174
|
-
)
|
|
175
|
-
) {
|
|
176
|
-
const fallbackText = addedLines.join('\n');
|
|
177
|
-
const fullContent = fileContents?.[file.path];
|
|
266
|
+
if (hasMatchBounds) {
|
|
267
|
+
const fullContent = normalizedFileContents[file.path];
|
|
178
268
|
const haystack =
|
|
179
269
|
rule.evaluationMode === 'full_file' && typeof fullContent === 'string'
|
|
180
270
|
? fullContent
|
|
181
|
-
:
|
|
271
|
+
: addedLines;
|
|
182
272
|
const matchCount = countMatches(rule.pattern, haystack);
|
|
183
273
|
if (
|
|
184
274
|
typeof rule.maxMatchesPerFile === 'number'
|