@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/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.hunks.flatMap((hunk) =>
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
- : fallbackText;
271
+ : addedLines;
182
272
  const matchCount = countMatches(rule.pattern, haystack);
183
273
  if (
184
274
  typeof rule.maxMatchesPerFile === 'number'