@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neurcode-ai/governance-runtime",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Deterministic plan/diff governance runtime shared by CLI, API, and integrations",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -7,13 +7,22 @@ export interface DeterministicConstraintRule {
7
7
  displayName: string;
8
8
  pattern: RegExp;
9
9
  matchToken: string;
10
+ provenance?: DeterministicConstraintProvenance;
10
11
  pathIncludePatterns?: string[];
11
12
  pathExcludePatterns?: string[];
12
13
  pathIncludes?: RegExp[];
13
14
  pathExcludes?: RegExp[];
14
15
  minMatchesPerFile?: number;
15
16
  maxMatchesPerFile?: number;
16
- evaluationMode?: 'added_lines' | 'full_file';
17
+ evaluationMode?: 'added_lines' | 'full_file' | 'signature_delta';
18
+ evaluationScope?: 'file' | 'repo';
19
+ }
20
+
21
+ export interface DeterministicConstraintProvenance {
22
+ why: string;
23
+ evidence: string[];
24
+ contributingGraphPaths: string[];
25
+ trustBoundaries: string[];
17
26
  }
18
27
 
19
28
  export interface DeterministicConstraintCompilation {
@@ -34,6 +43,11 @@ interface ConstraintTemplate {
34
43
  matchToken: string;
35
44
  }
36
45
 
46
+ interface PathScopeMatch {
47
+ includePatterns: string[];
48
+ excludePatterns: string[];
49
+ }
50
+
37
51
  const PROHIBITIVE_PATTERN = /\b(no|do not|don't|without|avoid|ban|disallow|never|must not)\b/i;
38
52
 
39
53
  const CONSTRAINT_TEMPLATES: ConstraintTemplate[] = [
@@ -160,10 +174,7 @@ function compilePathPatterns(patterns: string[]): RegExp[] {
160
174
  .filter((item): item is RegExp => item instanceof RegExp);
161
175
  }
162
176
 
163
- function parsePathScopes(statement: string): {
164
- includePatterns: string[];
165
- excludePatterns: string[];
166
- } {
177
+ function parsePathScopes(statement: string): PathScopeMatch {
167
178
  const includePatterns = new Set<string>();
168
179
  const excludePatterns = new Set<string>();
169
180
 
@@ -189,6 +200,51 @@ function parsePathScopes(statement: string): {
189
200
  };
190
201
  }
191
202
 
203
+ function uniqueSorted(values: string[]): string[] {
204
+ return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right));
205
+ }
206
+
207
+ function buildProvenance(input: {
208
+ why: string;
209
+ evidence: string[];
210
+ trustBoundaries: string[];
211
+ pathScopes: PathScopeMatch;
212
+ }): DeterministicConstraintProvenance {
213
+ const contributingGraphPaths = input.pathScopes.includePatterns.length > 0
214
+ ? uniqueSorted(input.pathScopes.includePatterns)
215
+ : ['<repo-scope>'];
216
+ return {
217
+ why: input.why,
218
+ evidence: uniqueSorted(input.evidence),
219
+ contributingGraphPaths,
220
+ trustBoundaries: uniqueSorted(input.trustBoundaries),
221
+ };
222
+ }
223
+
224
+ function applyPathScopes(
225
+ rule: Omit<DeterministicConstraintRule, 'pathIncludePatterns' | 'pathExcludePatterns' | 'pathIncludes' | 'pathExcludes'>,
226
+ pathScopes: PathScopeMatch
227
+ ): DeterministicConstraintRule {
228
+ const includePatterns = [...pathScopes.includePatterns];
229
+ const excludePatterns = [...pathScopes.excludePatterns];
230
+
231
+ return {
232
+ ...rule,
233
+ ...(includePatterns.length > 0
234
+ ? {
235
+ pathIncludePatterns: includePatterns,
236
+ pathIncludes: compilePathPatterns(includePatterns),
237
+ }
238
+ : {}),
239
+ ...(excludePatterns.length > 0
240
+ ? {
241
+ pathExcludePatterns: excludePatterns,
242
+ pathExcludes: compilePathPatterns(excludePatterns),
243
+ }
244
+ : {}),
245
+ };
246
+ }
247
+
192
248
  function escapeRegex(value: string): string {
193
249
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
194
250
  }
@@ -196,7 +252,7 @@ function escapeRegex(value: string): string {
196
252
  function parseInvocationLimitRule(
197
253
  statement: string,
198
254
  source: DeterministicConstraintSource,
199
- pathScopes: { includePatterns: string[]; excludePatterns: string[] }
255
+ pathScopes: PathScopeMatch
200
256
  ): DeterministicConstraintRule | null {
201
257
  const normalized = normalizeStatement(statement);
202
258
 
@@ -279,8 +335,9 @@ function parseInvocationLimitRule(
279
335
  return null;
280
336
  }
281
337
 
282
- const includePatterns = [...pathScopes.includePatterns];
283
- const excludePatterns = [...pathScopes.excludePatterns];
338
+ const repoScopeHint = /\b(across\s+(?:the\s+)?(?:repo|repository|codebase)|globally|in\s+the\s+entire\s+repo)\b/i.test(
339
+ normalized
340
+ );
284
341
  const displaySuffix =
285
342
  comparator === 'exact'
286
343
  ? `exactly ${limit}`
@@ -290,29 +347,324 @@ function parseInvocationLimitRule(
290
347
  const minMatches = comparator === 'min' || comparator === 'exact' ? limit : undefined;
291
348
  const maxMatches = comparator === 'max' || comparator === 'exact' ? limit : undefined;
292
349
 
293
- return {
294
- id: `${source}:${comparator}_invocations_${fnName.toLowerCase()}`,
295
- source,
296
- statement,
297
- displayName: `${fnName}() invocation limit (${displaySuffix})`,
298
- pattern: new RegExp(`(?<!function\\s)\\b${escapeRegex(fnName)}\\s*\\(`, 'i'),
299
- matchToken: `${fnName}(`,
300
- ...(typeof minMatches === 'number' ? { minMatchesPerFile: minMatches } : {}),
301
- ...(typeof maxMatches === 'number' ? { maxMatchesPerFile: maxMatches } : {}),
302
- evaluationMode: 'full_file',
303
- ...(includePatterns.length > 0
304
- ? {
305
- pathIncludePatterns: includePatterns,
306
- pathIncludes: compilePathPatterns(includePatterns),
307
- }
308
- : {}),
309
- ...(excludePatterns.length > 0
310
- ? {
311
- pathExcludePatterns: excludePatterns,
312
- pathExcludes: compilePathPatterns(excludePatterns),
313
- }
314
- : {}),
315
- };
350
+ return applyPathScopes(
351
+ {
352
+ id: `${source}:${comparator}_invocations_${fnName.toLowerCase()}${repoScopeHint ? '_repo' : ''}`,
353
+ source,
354
+ statement,
355
+ displayName: `${fnName}() invocation limit (${displaySuffix}${repoScopeHint ? ', repo-wide' : ''})`,
356
+ pattern: new RegExp(`(?<!function\\s)\\b${escapeRegex(fnName)}\\s*\\(`, 'i'),
357
+ matchToken: `${fnName}(`,
358
+ ...(typeof minMatches === 'number' ? { minMatchesPerFile: minMatches } : {}),
359
+ ...(typeof maxMatches === 'number' ? { maxMatchesPerFile: maxMatches } : {}),
360
+ evaluationMode: 'full_file',
361
+ evaluationScope: repoScopeHint ? 'repo' : 'file',
362
+ provenance: buildProvenance({
363
+ why: 'Statement imposes deterministic invocation cardinality limits.',
364
+ evidence: [fnName, rawLimit, comparator, repoScopeHint ? 'repo-scope' : 'file-scope'],
365
+ trustBoundaries: repoScopeHint ? ['cross-module'] : ['local-module'],
366
+ pathScopes,
367
+ }),
368
+ },
369
+ pathScopes
370
+ );
371
+ }
372
+
373
+ const EXPORTED_SIGNATURE_PATTERN =
374
+ /\bexport\s+(?:async\s+)?function\s+[A-Za-z_$][A-Za-z0-9_$]*\s*\(|\bexport\s+const\s+[A-Za-z_$][A-Za-z0-9_$]*\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|\bexport\s+(?:interface|type)\s+[A-Za-z_$][A-Za-z0-9_$]*/i;
375
+
376
+ function parseSignatureDriftRule(
377
+ statement: string,
378
+ source: DeterministicConstraintSource,
379
+ pathScopes: PathScopeMatch
380
+ ): DeterministicConstraintRule | null {
381
+ const normalized = normalizeStatement(statement);
382
+ const mentionsSignature =
383
+ /\b(signature|contract|public api|api surface|exported api)\b/i.test(normalized);
384
+ const mentionsChange =
385
+ /\b(change|changed|modify|modified|drift|mutation|alter)\b/i.test(normalized);
386
+ const prohibitive =
387
+ PROHIBITIVE_PATTERN.test(normalized)
388
+ || /\bkeep\b/i.test(normalized)
389
+ || /\bpreserve\b/i.test(normalized);
390
+
391
+ if (!mentionsSignature || !mentionsChange || !prohibitive) {
392
+ return null;
393
+ }
394
+
395
+ return applyPathScopes(
396
+ {
397
+ id: `${source}:no_api_signature_drift`,
398
+ source,
399
+ statement,
400
+ displayName: 'No exported API signature drift',
401
+ pattern: EXPORTED_SIGNATURE_PATTERN,
402
+ matchToken: 'api-signature-drift',
403
+ evaluationMode: 'signature_delta',
404
+ evaluationScope: 'file',
405
+ provenance: buildProvenance({
406
+ why: 'Statement protects external API signature stability.',
407
+ evidence: ['signature', 'public api', 'change/modify'],
408
+ trustBoundaries: ['public-api', 'external-consumer'],
409
+ pathScopes,
410
+ }),
411
+ },
412
+ pathScopes
413
+ );
414
+ }
415
+
416
+ function parseBackwardCompatibilityRule(
417
+ statement: string,
418
+ source: DeterministicConstraintSource,
419
+ pathScopes: PathScopeMatch
420
+ ): DeterministicConstraintRule | null {
421
+ const normalized = normalizeStatement(statement);
422
+ const mentionsCompatibility =
423
+ /\b(backward compatibility|backwards compatibility|breaking change|non-breaking|existing consumers?)\b/i.test(
424
+ normalized
425
+ );
426
+
427
+ if (!mentionsCompatibility) {
428
+ return null;
429
+ }
430
+
431
+ return applyPathScopes(
432
+ {
433
+ id: `${source}:backward_compatibility`,
434
+ source,
435
+ statement,
436
+ displayName: 'Backward compatibility guard (public contract drift)',
437
+ pattern: EXPORTED_SIGNATURE_PATTERN,
438
+ matchToken: 'backward-compatibility',
439
+ evaluationMode: 'signature_delta',
440
+ evaluationScope: 'file',
441
+ provenance: buildProvenance({
442
+ why: 'Statement requires non-breaking compatibility guarantees for existing consumers.',
443
+ evidence: ['backward compatibility', 'breaking change', 'existing consumers'],
444
+ trustBoundaries: ['public-api', 'external-consumer'],
445
+ pathScopes,
446
+ }),
447
+ },
448
+ pathScopes
449
+ );
450
+ }
451
+
452
+ function parseAsyncOrderingRule(
453
+ statement: string,
454
+ source: DeterministicConstraintSource,
455
+ pathScopes: PathScopeMatch
456
+ ): DeterministicConstraintRule | null {
457
+ const normalized = normalizeStatement(statement);
458
+ const mentionsOrdering =
459
+ /\b(async ordering|message ordering|out of order|preserve order|ordered workflow|ordering guarantees?|fifo)\b/i.test(
460
+ normalized
461
+ );
462
+ const mentionsParallelRisk =
463
+ /\b(parallel|promise\.all|allsettled|race|fan-?out|parallelize)\b/i.test(normalized);
464
+
465
+ if (!mentionsOrdering && !mentionsParallelRisk) {
466
+ return null;
467
+ }
468
+
469
+ return applyPathScopes(
470
+ {
471
+ id: `${source}:async_ordering`,
472
+ source,
473
+ statement,
474
+ displayName: 'Async ordering guard (parallelization risk)',
475
+ pattern: /\bPromise\.(?:all|allSettled|race|any)\s*\(|\bp-?map\s*\(|\bparallel(?:ize|Map)?\s*\(/i,
476
+ matchToken: 'async-ordering',
477
+ evaluationMode: 'added_lines',
478
+ evaluationScope: 'file',
479
+ provenance: buildProvenance({
480
+ why: 'Statement flags async ordering sensitivity and blocks risky parallel fan-out patterns.',
481
+ evidence: ['ordering', 'out of order', 'parallel execution'],
482
+ trustBoundaries: ['async-workflow', 'downstream-capacity'],
483
+ pathScopes,
484
+ }),
485
+ },
486
+ pathScopes
487
+ );
488
+ }
489
+
490
+ function parseEventSchemaConsistencyRule(
491
+ statement: string,
492
+ source: DeterministicConstraintSource,
493
+ pathScopes: PathScopeMatch
494
+ ): DeterministicConstraintRule | null {
495
+ const normalized = normalizeStatement(statement);
496
+ const mentionsEventSchema =
497
+ /\b(event schema|event payload|event contract|subscriber|downstream event|schema evolution|required fields?)\b/i.test(
498
+ normalized
499
+ );
500
+
501
+ if (!mentionsEventSchema) {
502
+ return null;
503
+ }
504
+
505
+ return applyPathScopes(
506
+ {
507
+ id: `${source}:event_schema_consistency`,
508
+ source,
509
+ statement,
510
+ displayName: 'Event/schema consistency guard',
511
+ pattern:
512
+ /\b(?:interface|type)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:Event|Payload|Message|Envelope)\b|\bevent[A-Za-z0-9_$]*\s*:\s*|\bschemaVersion\b/i,
513
+ matchToken: 'event-schema-drift',
514
+ evaluationMode: 'signature_delta',
515
+ evaluationScope: 'file',
516
+ provenance: buildProvenance({
517
+ why: 'Statement requires event contract continuity for downstream consumers.',
518
+ evidence: ['event schema', 'subscriber', 'required fields'],
519
+ trustBoundaries: ['event-bus', 'external-subscriber'],
520
+ pathScopes,
521
+ }),
522
+ },
523
+ pathScopes
524
+ );
525
+ }
526
+
527
+ function parseMultiTenantIsolationRule(
528
+ statement: string,
529
+ source: DeterministicConstraintSource,
530
+ pathScopes: PathScopeMatch
531
+ ): DeterministicConstraintRule | null {
532
+ const normalized = normalizeStatement(statement);
533
+ const mentionsTenantIsolation =
534
+ /\b(multi-tenant|tenant isolation|cross-tenant|tenant boundaries?|tenant_id|tenant guard)\b/i.test(normalized);
535
+
536
+ if (!mentionsTenantIsolation) {
537
+ return null;
538
+ }
539
+
540
+ return applyPathScopes(
541
+ {
542
+ id: `${source}:multi_tenant_isolation`,
543
+ source,
544
+ statement,
545
+ displayName: 'Multi-tenant isolation guard',
546
+ pattern:
547
+ /\b(?:bypassTenant(?:Guard|Scope)?|ignoreTenant(?:Scope)?|crossTenant|allTenants|tenantScope\s*:\s*false|setTenantContext\s*\(\s*null\s*\)|withoutTenantScope)\b/i,
548
+ matchToken: 'tenant-isolation',
549
+ evaluationMode: 'full_file',
550
+ evaluationScope: 'file',
551
+ provenance: buildProvenance({
552
+ why: 'Statement enforces tenant boundary safety and isolation constraints.',
553
+ evidence: ['multi-tenant', 'tenant_id', 'cross-tenant'],
554
+ trustBoundaries: ['tenant-boundary', 'data-access-layer'],
555
+ pathScopes,
556
+ }),
557
+ },
558
+ pathScopes
559
+ );
560
+ }
561
+
562
+ function parseCacheInvariantRule(
563
+ statement: string,
564
+ source: DeterministicConstraintSource,
565
+ pathScopes: PathScopeMatch
566
+ ): DeterministicConstraintRule | null {
567
+ const normalized = normalizeStatement(statement);
568
+ const mentionsCache = /\b(cache invalidation|cache invariant|cache keys?|cache consistency|evict cache|clear(?:ing)? (?:shared )?cache|shared cache|invalidate .*cache)\b/i.test(
569
+ normalized
570
+ );
571
+
572
+ if (!mentionsCache) {
573
+ return null;
574
+ }
575
+
576
+ return applyPathScopes(
577
+ {
578
+ id: `${source}:cache_invariant`,
579
+ source,
580
+ statement,
581
+ displayName: 'Cache invariant guard (global invalidation risk)',
582
+ pattern:
583
+ /\b(?:cache\.(?:clear|reset)\s*\(\s*\)|invalidateAll\s*\(|flushAll\s*\(|redis\.flush(?:all|db)\s*\(|\bFLUSH(?:ALL|DB)\b)\b/i,
584
+ matchToken: 'cache-invariant',
585
+ evaluationMode: 'added_lines',
586
+ evaluationScope: 'file',
587
+ provenance: buildProvenance({
588
+ why: 'Statement marks cache behavior as operationally sensitive and blocks global flush patterns.',
589
+ evidence: ['cache invalidation', 'cache key', 'clear cache'],
590
+ trustBoundaries: ['cache-layer', 'operational-safety'],
591
+ pathScopes,
592
+ }),
593
+ },
594
+ pathScopes
595
+ );
596
+ }
597
+
598
+ function parseIdempotencyRule(
599
+ statement: string,
600
+ source: DeterministicConstraintSource,
601
+ pathScopes: PathScopeMatch
602
+ ): DeterministicConstraintRule | null {
603
+ const normalized = normalizeStatement(statement);
604
+ const mentionsIdempotency =
605
+ /\b(idempotency|idempotent|retryable write|retries|exactly once|at-least-once)\b/i.test(normalized);
606
+
607
+ if (!mentionsIdempotency) {
608
+ return null;
609
+ }
610
+
611
+ return applyPathScopes(
612
+ {
613
+ id: `${source}:idempotency`,
614
+ source,
615
+ statement,
616
+ displayName: 'Idempotency expectation guard',
617
+ pattern: /\b(?:idempotency[-_ ]key|x-idempotency-key|idempotencyKey|dedupe(?:Key|Id)?)\b/i,
618
+ matchToken: 'idempotency-key',
619
+ minMatchesPerFile: 1,
620
+ evaluationMode: 'full_file',
621
+ evaluationScope: 'repo',
622
+ provenance: buildProvenance({
623
+ why: 'Statement requires deterministic retry safety via idempotency markers.',
624
+ evidence: ['idempotency', 'retryable write', 'exactly once'],
625
+ trustBoundaries: ['api-edge', 'write-path'],
626
+ pathScopes,
627
+ }),
628
+ },
629
+ pathScopes
630
+ );
631
+ }
632
+
633
+ function parseMigrationSafetyRule(
634
+ statement: string,
635
+ source: DeterministicConstraintSource,
636
+ pathScopes: PathScopeMatch
637
+ ): DeterministicConstraintRule | null {
638
+ const normalized = normalizeStatement(statement);
639
+ const mentionsMigration =
640
+ /\b(migration safety|schema migration|destructive migration|drop column|drop table|truncate table|backfill safety)\b/i.test(
641
+ normalized
642
+ );
643
+
644
+ if (!mentionsMigration) {
645
+ return null;
646
+ }
647
+
648
+ return applyPathScopes(
649
+ {
650
+ id: `${source}:migration_safety`,
651
+ source,
652
+ statement,
653
+ displayName: 'Migration safety guard (destructive operation risk)',
654
+ pattern:
655
+ /\b(?:DROP\s+COLUMN|DROP\s+TABLE|TRUNCATE\s+TABLE|ALTER\s+TABLE\s+[A-Za-z0-9_`".]+\s+DROP\s+COLUMN|DELETE\s+FROM\s+[A-Za-z0-9_`".]+\s*;)\b/i,
656
+ matchToken: 'destructive-migration',
657
+ evaluationMode: 'added_lines',
658
+ evaluationScope: 'file',
659
+ provenance: buildProvenance({
660
+ why: 'Statement requires non-destructive migration behavior for production safety.',
661
+ evidence: ['migration safety', 'drop column', 'truncate table'],
662
+ trustBoundaries: ['data-store', 'migration-pipeline'],
663
+ pathScopes,
664
+ }),
665
+ },
666
+ pathScopes
667
+ );
316
668
  }
317
669
 
318
670
  function statementMatchesTemplate(normalizedStatement: string, template: ConstraintTemplate): boolean {
@@ -323,30 +675,25 @@ function createRule(
323
675
  template: ConstraintTemplate,
324
676
  source: DeterministicConstraintSource,
325
677
  statement: string,
326
- pathScopes: { includePatterns: string[]; excludePatterns: string[] }
678
+ pathScopes: PathScopeMatch
327
679
  ): DeterministicConstraintRule {
328
- const includePatterns = [...pathScopes.includePatterns];
329
- const excludePatterns = [...pathScopes.excludePatterns];
330
- return {
331
- id: `${source}:${template.id}`,
332
- source,
333
- statement,
334
- displayName: template.displayName,
335
- pattern: template.pattern,
336
- matchToken: template.matchToken,
337
- ...(includePatterns.length > 0
338
- ? {
339
- pathIncludePatterns: includePatterns,
340
- pathIncludes: compilePathPatterns(includePatterns),
341
- }
342
- : {}),
343
- ...(excludePatterns.length > 0
344
- ? {
345
- pathExcludePatterns: excludePatterns,
346
- pathExcludes: compilePathPatterns(excludePatterns),
347
- }
348
- : {}),
349
- };
680
+ return applyPathScopes(
681
+ {
682
+ id: `${source}:${template.id}`,
683
+ source,
684
+ statement,
685
+ displayName: template.displayName,
686
+ pattern: template.pattern,
687
+ matchToken: template.matchToken,
688
+ provenance: buildProvenance({
689
+ why: 'Statement matched deterministic constraint template.',
690
+ evidence: template.triggerTokens,
691
+ trustBoundaries: ['code-hygiene'],
692
+ pathScopes,
693
+ }),
694
+ },
695
+ pathScopes
696
+ );
350
697
  }
351
698
 
352
699
  function compileStatements(
@@ -368,6 +715,54 @@ function compileStatements(
368
715
  continue;
369
716
  }
370
717
 
718
+ const signatureDriftRule = parseSignatureDriftRule(rawStatement, source, pathScopes);
719
+ if (signatureDriftRule) {
720
+ rules.push(signatureDriftRule);
721
+ continue;
722
+ }
723
+
724
+ const backwardCompatibilityRule = parseBackwardCompatibilityRule(rawStatement, source, pathScopes);
725
+ if (backwardCompatibilityRule) {
726
+ rules.push(backwardCompatibilityRule);
727
+ continue;
728
+ }
729
+
730
+ const asyncOrderingRule = parseAsyncOrderingRule(rawStatement, source, pathScopes);
731
+ if (asyncOrderingRule) {
732
+ rules.push(asyncOrderingRule);
733
+ continue;
734
+ }
735
+
736
+ const eventSchemaRule = parseEventSchemaConsistencyRule(rawStatement, source, pathScopes);
737
+ if (eventSchemaRule) {
738
+ rules.push(eventSchemaRule);
739
+ continue;
740
+ }
741
+
742
+ const multiTenantRule = parseMultiTenantIsolationRule(rawStatement, source, pathScopes);
743
+ if (multiTenantRule) {
744
+ rules.push(multiTenantRule);
745
+ continue;
746
+ }
747
+
748
+ const cacheInvariantRule = parseCacheInvariantRule(rawStatement, source, pathScopes);
749
+ if (cacheInvariantRule) {
750
+ rules.push(cacheInvariantRule);
751
+ continue;
752
+ }
753
+
754
+ const idempotencyRule = parseIdempotencyRule(rawStatement, source, pathScopes);
755
+ if (idempotencyRule) {
756
+ rules.push(idempotencyRule);
757
+ continue;
758
+ }
759
+
760
+ const migrationSafetyRule = parseMigrationSafetyRule(rawStatement, source, pathScopes);
761
+ if (migrationSafetyRule) {
762
+ rules.push(migrationSafetyRule);
763
+ continue;
764
+ }
765
+
371
766
  const requiresProhibitiveLanguage = source === 'intent';
372
767
  if (requiresProhibitiveLanguage && !PROHIBITIVE_PATTERN.test(normalized)) {
373
768
  continue;
@@ -403,6 +798,7 @@ function dedupeRules(rules: DeterministicConstraintRule[]): DeterministicConstra
403
798
  typeof rule.minMatchesPerFile === 'number' ? String(rule.minMatchesPerFile) : '',
404
799
  typeof rule.maxMatchesPerFile === 'number' ? String(rule.maxMatchesPerFile) : '',
405
800
  rule.evaluationMode || '',
801
+ rule.evaluationScope || '',
406
802
  ].join('::');
407
803
  if (seen.has(key)) {
408
804
  continue;