@neurcode-ai/governance-runtime 0.1.1 → 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
@@ -157,6 +157,16 @@ test('compileDeterministicConstraints builds rules from intent and policy text',
157
157
  assert.ok(ruleIds.includes('policy:no_process_env'));
158
158
  });
159
159
 
160
+ test('compileDeterministicConstraints supports path-scoped rules', () => {
161
+ const compiled = compileDeterministicConstraints({
162
+ policyRules: ['No console.log in src/server/** except src/server/tests/**'],
163
+ });
164
+ const scopedRule = compiled.rules.find((rule) => rule.id === 'policy:no_console_log');
165
+ assert.ok(scopedRule, 'expected path-scoped console.log rule');
166
+ assert.deepEqual(scopedRule?.pathIncludePatterns, ['src/server/**']);
167
+ assert.deepEqual(scopedRule?.pathExcludePatterns, ['src/server/tests/**']);
168
+ });
169
+
160
170
  test('evaluatePlanVerification enforces compiled policy rules deterministically', () => {
161
171
  const result = evaluatePlanVerification({
162
172
  planFiles: SAMPLE_PLAN,
@@ -190,6 +200,294 @@ test('evaluatePlanVerification enforces compiled policy rules deterministically'
190
200
  assert.match(result.constraintViolations[0], /console\.log/i);
191
201
  });
192
202
 
203
+ test('evaluatePlanVerification enforces path-scoped exclusions to reduce false positives', () => {
204
+ const result = evaluatePlanVerification({
205
+ planFiles: SAMPLE_PLAN,
206
+ changedFiles: [
207
+ {
208
+ path: 'src/server/app.ts',
209
+ changeType: 'modify',
210
+ added: 1,
211
+ removed: 0,
212
+ hunks: [
213
+ {
214
+ oldStart: 1,
215
+ oldLines: 0,
216
+ newStart: 1,
217
+ newLines: 1,
218
+ lines: [
219
+ {
220
+ type: 'added',
221
+ content: 'console.log("server");',
222
+ },
223
+ ],
224
+ },
225
+ ],
226
+ },
227
+ {
228
+ path: 'src/server/tests/app.test.ts',
229
+ changeType: 'modify',
230
+ added: 1,
231
+ removed: 0,
232
+ hunks: [
233
+ {
234
+ oldStart: 1,
235
+ oldLines: 0,
236
+ newStart: 1,
237
+ newLines: 1,
238
+ lines: [
239
+ {
240
+ type: 'added',
241
+ content: 'console.log("test");',
242
+ },
243
+ ],
244
+ },
245
+ ],
246
+ },
247
+ ],
248
+ policyRules: ['No console.log in src/server/** except src/server/tests/**'],
249
+ });
250
+
251
+ assert.equal(result.constraintViolations.length, 1);
252
+ assert.match(result.constraintViolations[0], /src\/server\/app\.ts/i);
253
+ });
254
+
255
+ test('evaluatePlanVerification supports deterministic invocation limits with full-file context', () => {
256
+ const result = evaluatePlanVerification({
257
+ planFiles: SAMPLE_PLAN,
258
+ changedFiles: [
259
+ {
260
+ path: 'src/a.ts',
261
+ changeType: 'modify',
262
+ added: 1,
263
+ removed: 0,
264
+ hunks: [
265
+ {
266
+ oldStart: 1,
267
+ oldLines: 0,
268
+ newStart: 1,
269
+ newLines: 1,
270
+ lines: [
271
+ {
272
+ type: 'added',
273
+ content: 'const marker = true;',
274
+ },
275
+ ],
276
+ },
277
+ ],
278
+ },
279
+ ],
280
+ intentConstraints: 'foo invoked only 1 times in src/a.ts',
281
+ fileContents: {
282
+ 'src/a.ts': 'foo();\nfoo();\n',
283
+ },
284
+ });
285
+
286
+ assert.equal(result.verdict, 'FAIL');
287
+ assert.equal(result.constraintViolations.length, 1);
288
+ assert.match(result.constraintViolations[0], /limit 1/i);
289
+ });
290
+
291
+ test('evaluatePlanVerification supports at-least invocation constraints', () => {
292
+ const result = evaluatePlanVerification({
293
+ planFiles: SAMPLE_PLAN,
294
+ changedFiles: [
295
+ {
296
+ path: 'src/a.ts',
297
+ changeType: 'modify',
298
+ added: 1,
299
+ removed: 0,
300
+ hunks: [
301
+ {
302
+ oldStart: 1,
303
+ oldLines: 0,
304
+ newStart: 1,
305
+ newLines: 1,
306
+ lines: [
307
+ {
308
+ type: 'added',
309
+ content: 'const marker = true;',
310
+ },
311
+ ],
312
+ },
313
+ ],
314
+ },
315
+ ],
316
+ intentConstraints: 'foo should be called at least 2 times in src/a.ts',
317
+ fileContents: {
318
+ 'src/a.ts': 'foo();\n',
319
+ },
320
+ });
321
+
322
+ assert.equal(result.verdict, 'FAIL');
323
+ assert.equal(result.constraintViolations.length, 1);
324
+ assert.match(result.constraintViolations[0], /minimum 2/i);
325
+ });
326
+
327
+ test('compileDeterministicConstraints supports exact invocation constraints', () => {
328
+ const compiled = compileDeterministicConstraints({
329
+ intentConstraints: 'bar should be called exactly 3 times in src/a.ts',
330
+ });
331
+
332
+ const rule = compiled.rules.find((item) => item.id.includes('exact_invocations_bar'));
333
+ assert.ok(rule, 'expected exact invocation rule');
334
+ assert.equal(rule?.minMatchesPerFile, 3);
335
+ assert.equal(rule?.maxMatchesPerFile, 3);
336
+ });
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
+
421
+ test('evaluatePlanVerification detects network-call invariants deterministically', () => {
422
+ const result = evaluatePlanVerification({
423
+ planFiles: SAMPLE_PLAN,
424
+ changedFiles: [
425
+ {
426
+ path: 'src/a.ts',
427
+ changeType: 'modify',
428
+ added: 1,
429
+ removed: 0,
430
+ hunks: [
431
+ {
432
+ oldStart: 1,
433
+ oldLines: 0,
434
+ newStart: 1,
435
+ newLines: 1,
436
+ lines: [
437
+ {
438
+ type: 'added',
439
+ content: 'await fetch("https://api.example.com");',
440
+ },
441
+ ],
442
+ },
443
+ ],
444
+ },
445
+ ],
446
+ policyRules: ['No network calls in committed code'],
447
+ });
448
+
449
+ assert.equal(result.verdict, 'FAIL');
450
+ assert.equal(result.constraintViolations.length, 1);
451
+ assert.match(result.constraintViolations[0], /network-call/i);
452
+ });
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
+
193
491
  test('buildPlanVerificationMessage handles incomplete 0/0 plans', () => {
194
492
  const message = buildPlanVerificationMessage({
195
493
  constraintViolations: [],
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export interface PlanVerificationInput {
49
49
  intentConstraints?: string;
50
50
  policyRules?: string[];
51
51
  extraConstraintRules?: DeterministicConstraintRule[];
52
+ fileContents?: Record<string, string>;
52
53
  }
53
54
 
54
55
  export interface PlanDiffSummary {
@@ -105,7 +106,8 @@ function detectConstraintViolations(
105
106
  intentConstraints: string | undefined,
106
107
  policyRules: string[] | undefined,
107
108
  extraConstraintRules: DeterministicConstraintRule[] | undefined,
108
- changedFiles: PlanDiffFile[]
109
+ changedFiles: PlanDiffFile[],
110
+ fileContents?: Record<string, string>
109
111
  ): string[] {
110
112
  const compiled = compileDeterministicConstraints({
111
113
  intentConstraints,
@@ -123,18 +125,187 @@ function detectConstraintViolations(
123
125
 
124
126
  const violations: string[] = [];
125
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
+ }
132
+
133
+ const pathMatchesRule = (rule: DeterministicConstraintRule, filePath: string): boolean => {
134
+ const include = rule.pathIncludes || [];
135
+ const exclude = rule.pathExcludes || [];
136
+ if (include.length > 0 && !include.some((pattern) => pattern.test(filePath))) {
137
+ return false;
138
+ }
139
+ if (exclude.length > 0 && exclude.some((pattern) => pattern.test(filePath))) {
140
+ return false;
141
+ }
142
+ return true;
143
+ };
144
+
145
+ const countMatches = (pattern: RegExp, input: string): number => {
146
+ if (!input) return 0;
147
+ const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
148
+ const re = new RegExp(pattern.source, flags);
149
+ let total = 0;
150
+ for (const _match of input.matchAll(re)) {
151
+ total += 1;
152
+ }
153
+ return total;
154
+ };
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
+ ]);
126
170
 
127
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
+
128
256
  for (const file of changedFiles) {
257
+ if (!pathMatchesRule(rule, file.path)) {
258
+ continue;
259
+ }
129
260
  if (!file.hunks || file.hunks.length === 0) {
130
261
  continue;
131
262
  }
263
+
264
+ const addedLines = addedLinesByPath.get(file.path) || '';
265
+
266
+ if (hasMatchBounds) {
267
+ const fullContent = normalizedFileContents[file.path];
268
+ const haystack =
269
+ rule.evaluationMode === 'full_file' && typeof fullContent === 'string'
270
+ ? fullContent
271
+ : addedLines;
272
+ const matchCount = countMatches(rule.pattern, haystack);
273
+ if (
274
+ typeof rule.maxMatchesPerFile === 'number'
275
+ && Number.isFinite(rule.maxMatchesPerFile)
276
+ && matchCount > rule.maxMatchesPerFile
277
+ ) {
278
+ const violation =
279
+ `${rule.matchToken} matched ${matchCount} times in ${file.path} ` +
280
+ `(limit ${rule.maxMatchesPerFile}, violates constraint: "${rule.displayName}")`;
281
+ if (!seenViolations.has(violation)) {
282
+ seenViolations.add(violation);
283
+ violations.push(violation);
284
+ }
285
+ }
286
+ if (
287
+ typeof rule.minMatchesPerFile === 'number'
288
+ && Number.isFinite(rule.minMatchesPerFile)
289
+ && matchCount < rule.minMatchesPerFile
290
+ ) {
291
+ const violation =
292
+ `${rule.matchToken} matched ${matchCount} times in ${file.path} ` +
293
+ `(minimum ${rule.minMatchesPerFile}, violates constraint: "${rule.displayName}")`;
294
+ if (!seenViolations.has(violation)) {
295
+ seenViolations.add(violation);
296
+ violations.push(violation);
297
+ }
298
+ }
299
+ continue;
300
+ }
301
+
132
302
  for (const hunk of file.hunks) {
133
303
  for (const line of hunk.lines) {
134
304
  if (line.type !== 'added') {
135
305
  continue;
136
306
  }
137
- if (!rule.pattern.test(line.content)) {
307
+ const pattern = new RegExp(rule.pattern.source, rule.pattern.flags);
308
+ if (!pattern.test(line.content)) {
138
309
  continue;
139
310
  }
140
311
  const violation = `${rule.matchToken} found in ${file.path} (violates constraint: \"${rule.displayName}\")`;
@@ -234,7 +405,8 @@ export function evaluatePlanVerification(input: PlanVerificationInput): PlanVeri
234
405
  input.intentConstraints,
235
406
  input.policyRules,
236
407
  input.extraConstraintRules,
237
- normalizedChangedFiles
408
+ normalizedChangedFiles,
409
+ input.fileContents
238
410
  );
239
411
  const verdict = resolvePlanVerdict({
240
412
  bloatCount,