@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/dist/constraints.d.ts +15 -0
- package/dist/constraints.d.ts.map +1 -1
- package/dist/constraints.js +473 -5
- package/dist/constraints.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +139 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/constraints.ts +656 -11
- package/src/index.test.ts +298 -0
- package/src/index.ts +175 -3
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
|
-
|
|
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,
|