@codapult/guard 0.1.0 → 0.3.0

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.
@@ -1,11 +1,13 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createHash } from 'node:crypto';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync, } from 'node:fs';
4
- import { extname, relative, resolve, sep } from 'node:path';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
4
+ import { dirname, extname, relative, resolve, sep } from 'node:path';
5
5
  import { Project, SyntaxKind } from 'ts-morph';
6
- import { discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
6
+ import { buildModuleTargetGraph } from './discovery/discovery.js';
7
+ import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
7
8
  import { config } from './config.js';
8
9
  import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
10
+ import { analyzeProjectImpact } from './analysis/impact.js';
9
11
  import { guardAgentConfigSchema, guardConfigSchema, guardContractsFileSchema, guardProposalSchema, } from './policy/schemas.js';
10
12
  export function classifyGuardOutcome(input) {
11
13
  if (input.configured === false)
@@ -49,6 +51,18 @@ export class GuardAlreadyInitializedError extends Error {
49
51
  this.name = 'GuardAlreadyInitializedError';
50
52
  }
51
53
  }
54
+ export class GuardConfigError extends Error {
55
+ constructor(path) {
56
+ super(`Guard configuration is invalid or unsupported: ${path}`);
57
+ this.name = 'GuardConfigError';
58
+ }
59
+ }
60
+ export class GuardStateBusyError extends Error {
61
+ constructor(path) {
62
+ super(`Guard state is being updated by another process: ${path}`);
63
+ this.name = 'GuardStateBusyError';
64
+ }
65
+ }
52
66
  function isGuardConfig(value) {
53
67
  return guardConfigSchema.safeParse(value).success;
54
68
  }
@@ -61,9 +75,37 @@ function readJson(filePath) {
61
75
  }
62
76
  }
63
77
  function atomicWriteFile(path, content) {
64
- const temporaryPath = `${path}.tmp-${process.pid}`;
65
- writeFileSync(temporaryPath, content, 'utf8');
66
- renameSync(temporaryPath, path);
78
+ const lockPath = `${path}.lock`;
79
+ let lockAcquired = false;
80
+ try {
81
+ try {
82
+ writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
83
+ lockAcquired = true;
84
+ }
85
+ catch (error) {
86
+ if (error.code === 'EEXIST') {
87
+ try {
88
+ if (Date.now() - statSync(lockPath).mtimeMs > 60_000) {
89
+ unlinkSync(lockPath);
90
+ writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
91
+ lockAcquired = true;
92
+ }
93
+ }
94
+ catch {
95
+ // Another writer may have replaced or removed the lock.
96
+ }
97
+ }
98
+ if (!lockAcquired)
99
+ throw new GuardStateBusyError(path);
100
+ }
101
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
102
+ writeFileSync(temporaryPath, content, 'utf8');
103
+ renameSync(temporaryPath, path);
104
+ }
105
+ finally {
106
+ if (lockAcquired)
107
+ unlinkSync(lockPath);
108
+ }
67
109
  }
68
110
  function writeGuardArtifact(root, relativePath, value) {
69
111
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
@@ -107,14 +149,17 @@ export function getGuardProposalFreshness(model, proposals) {
107
149
  return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
108
150
  }
109
151
  export function loadGuardConfig(root) {
110
- const value = readJson(resolve(root, GUARD_RULES_FILE));
111
- if (!isGuardConfig(value))
152
+ const rulesPath = resolve(root, GUARD_RULES_FILE);
153
+ if (!existsSync(rulesPath))
112
154
  return undefined;
155
+ const value = readJson(rulesPath);
156
+ if (!isGuardConfig(value))
157
+ throw new GuardConfigError(GUARD_RULES_FILE);
113
158
  const contractsPath = resolve(root, GUARD_CONTRACTS_FILE);
114
159
  if (existsSync(contractsPath)) {
115
160
  const contractFile = readJson(contractsPath);
116
161
  if (!isGuardContractsFile(contractFile))
117
- return undefined;
162
+ throw new GuardConfigError(GUARD_CONTRACTS_FILE);
118
163
  return { ...value, contracts: contractFile.contracts };
119
164
  }
120
165
  return {
@@ -136,9 +181,6 @@ export function loadGuardAgentConfig(root) {
136
181
  export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
137
182
  writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
138
183
  }
139
- export function loadGuardArtifact(root, relativePath) {
140
- return readJson(resolve(root, relativePath));
141
- }
142
184
  export function loadBaseline(root) {
143
185
  const value = readJson(resolve(root, GUARD_BASELINE_FILE));
144
186
  if (!Array.isArray(value))
@@ -201,6 +243,31 @@ export function recordGuardProposalDecision(root, decisions) {
201
243
  decisions: [...(proposals.decisions ?? []), ...nextDecisions],
202
244
  });
203
245
  }
246
+ export function getPendingGuardProposals(proposals) {
247
+ if (!proposals)
248
+ return [];
249
+ const decisions = new Map();
250
+ for (const decision of proposals.decisions ?? []) {
251
+ const current = decisions.get(`${decision.type}:${decision.id}`);
252
+ if (!current || decision.decidedAt >= current.decidedAt) {
253
+ decisions.set(`${decision.type}:${decision.id}`, decision);
254
+ }
255
+ }
256
+ const matchesCurrentProposal = (decision) => decision !== undefined &&
257
+ (proposals.proposalId === undefined || decision.proposalId === proposals.proposalId) &&
258
+ (proposals.contentFingerprint === undefined ||
259
+ decision.proposalFingerprint === proposals.contentFingerprint) &&
260
+ (proposals.revision === undefined || decision.revision === proposals.revision);
261
+ return [
262
+ ...proposals.rules
263
+ .filter((rule) => rule.status === 'proposed' && !matchesCurrentProposal(decisions.get(`rule:${rule.id}`)))
264
+ .map((rule) => ({ id: rule.id, type: 'rule' })),
265
+ ...proposals.contracts
266
+ .filter((contract) => contract.status === 'proposed' &&
267
+ !matchesCurrentProposal(decisions.get(`contract:${contract.id}`)))
268
+ .map((contract) => ({ id: contract.id, type: 'contract' })),
269
+ ];
270
+ }
204
271
  export function buildGuardProposals(model, guardConfig) {
205
272
  const capabilities = Object.keys(model.capabilities);
206
273
  const questions = [];
@@ -346,14 +413,19 @@ export function buildConventionsMemory(model) {
346
413
  }
347
414
  export function buildGeneratedGuardConfig(model) {
348
415
  const isPersistenceImport = (importPath) => /(?:^|\/)(?:database|db)(?:\/|$)/i.test(importPath) ||
349
- /^(?:@prisma\codapult-guardent|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
416
+ /^(?:@prisma\/client|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
417
+ const moduleReferences = (module) => [
418
+ ...module.imports,
419
+ ...module.exports,
420
+ ...module.dynamicImports,
421
+ ];
350
422
  const persistenceImports = [
351
- ...new Set(model.modules.flatMap((module) => module.imports).filter(isPersistenceImport)),
423
+ ...new Set(model.modules.flatMap((module) => moduleReferences(module)).filter(isPersistenceImport)),
352
424
  ].sort();
353
425
  const clientPersistenceFiles = model.insights.boundaries
354
426
  .find((boundary) => boundary.kind === 'client')
355
427
  ?.files.filter((file) => model.modules.some((module) => module.path === file &&
356
- module.imports.some((importPath) => persistenceImports.includes(importPath)))) ?? [];
428
+ moduleReferences(module).some((importPath) => persistenceImports.includes(importPath)))) ?? [];
357
429
  const proposedRules = clientPersistenceFiles.length > 0
358
430
  ? [
359
431
  {
@@ -423,6 +495,32 @@ export function writeProjectSnapshot(root, model) {
423
495
  atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
424
496
  return revision;
425
497
  }
498
+ function isProjectPath(root, value) {
499
+ const normalized = value.replace(/\\/g, '/');
500
+ if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
501
+ return false;
502
+ }
503
+ try {
504
+ const projectRoot = `${realpathSync(root)}${sep}`;
505
+ const targetPath = resolve(root, normalized);
506
+ let target;
507
+ try {
508
+ target = realpathSync(targetPath);
509
+ }
510
+ catch {
511
+ target = realpathSync(dirname(targetPath));
512
+ }
513
+ return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
514
+ }
515
+ catch {
516
+ return false;
517
+ }
518
+ }
519
+ export function loadGuardArtifact(root, relativePath) {
520
+ if (!isProjectPath(root, relativePath))
521
+ return undefined;
522
+ return readJson(resolve(root, relativePath));
523
+ }
426
524
  export function validateGuardContracts(root, contracts = []) {
427
525
  const issues = [];
428
526
  for (const contract of contracts) {
@@ -444,8 +542,18 @@ export function validateGuardContracts(root, contracts = []) {
444
542
  message: 'Required-call contracts need at least one mustCall pattern.',
445
543
  });
446
544
  }
545
+ if (contract.kind === 'package-boundary' &&
546
+ ((contract.fromPackages?.length ?? 0) === 0 ||
547
+ (contract.mustNotImportPackages?.length ?? 0) === 0)) {
548
+ issues.push({
549
+ contractId: contract.id,
550
+ field: 'definition',
551
+ value: contract.kind,
552
+ message: 'Package-boundary contracts need fromPackages and mustNotImportPackages patterns.',
553
+ });
554
+ }
447
555
  for (const scope of contract.scope ?? []) {
448
- if (!existsSync(resolve(root, scope))) {
556
+ if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
449
557
  issues.push({
450
558
  contractId: contract.id,
451
559
  field: 'scope',
@@ -455,7 +563,7 @@ export function validateGuardContracts(root, contracts = []) {
455
563
  }
456
564
  }
457
565
  for (const entrypoint of contract.entrypoints ?? []) {
458
- if (!existsSync(resolve(root, entrypoint))) {
566
+ if (!isProjectPath(root, entrypoint) || !existsSync(resolve(root, entrypoint))) {
459
567
  issues.push({
460
568
  contractId: contract.id,
461
569
  field: 'scope',
@@ -465,7 +573,7 @@ export function validateGuardContracts(root, contracts = []) {
465
573
  }
466
574
  }
467
575
  for (const excluded of contract.exclude ?? []) {
468
- if (!existsSync(resolve(root, excluded))) {
576
+ if (!isProjectPath(root, excluded) || !existsSync(resolve(root, excluded))) {
469
577
  issues.push({
470
578
  contractId: contract.id,
471
579
  field: 'scope',
@@ -475,7 +583,7 @@ export function validateGuardContracts(root, contracts = []) {
475
583
  }
476
584
  }
477
585
  for (const reference of contract.references ?? []) {
478
- if (!existsSync(resolve(root, reference))) {
586
+ if (!isProjectPath(root, reference) || !existsSync(resolve(root, reference))) {
479
587
  issues.push({
480
588
  contractId: contract.id,
481
589
  field: 'reference',
@@ -540,6 +648,32 @@ function importLine(sourceFile, importPath) {
540
648
  });
541
649
  return dynamicImport?.getStartLineNumber() ?? 1;
542
650
  }
651
+ function moduleImportReferences(module) {
652
+ const sources = [...new Set([...module.imports, ...module.exports, ...module.dynamicImports])];
653
+ return sources.map((source) => ({ source, resolved: module.resolvedImportMap?.[source] }));
654
+ }
655
+ function packageSelectorMatches(value, selector) {
656
+ if (!value)
657
+ return false;
658
+ return selector.endsWith('*') ? value.startsWith(selector.slice(0, -1)) : value === selector;
659
+ }
660
+ function packageForFile(model, file) {
661
+ const packageInfo = model.project.workspacePackages
662
+ .filter((workspace) => workspace.path === '.' ||
663
+ file === `${workspace.path}/package.json` ||
664
+ file.startsWith(`${workspace.path}/`))
665
+ .sort((left, right) => right.path.length - left.path.length)[0] ?? null;
666
+ return packageInfo === null ? undefined : (packageInfo.name ?? packageInfo.path);
667
+ }
668
+ function packageForImport(model, reference) {
669
+ const resolvedPackage = reference.resolved
670
+ ? packageForFile(model, reference.resolved)
671
+ : undefined;
672
+ if (resolvedPackage)
673
+ return resolvedPackage;
674
+ return model.project.workspacePackages.find((workspace) => workspace.name === reference.source ||
675
+ (workspace.name !== undefined && reference.source.startsWith(`${workspace.name}/`)))?.name;
676
+ }
543
677
  function scanFile(root, file, rules, module, astProject) {
544
678
  if (!module)
545
679
  return [];
@@ -554,26 +688,25 @@ function scanFile(root, file, rules, module, astProject) {
554
688
  }
555
689
  }
556
690
  const findings = [];
557
- const importPaths = [
558
- ...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
559
- ];
560
- for (const importPath of importPaths) {
561
- const line = sourceFile ? importLine(sourceFile, importPath) : 1;
691
+ for (const reference of moduleImportReferences(module)) {
692
+ const line = sourceFile ? importLine(sourceFile, reference.source) : 1;
562
693
  for (const rule of rules) {
563
694
  if (rule.status === 'proposed' || !ruleAppliesToFile(rule, file))
564
695
  continue;
565
696
  if (rule.kind === 'client-forbidden-import' && !isClient)
566
697
  continue;
567
- if (!rule.patterns.some((pattern) => patternMatches(importPath, pattern)))
698
+ if (!rule.patterns.some((pattern) => patternMatches(reference.source, pattern) ||
699
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))
568
700
  continue;
569
701
  findings.push({
570
702
  ruleId: rule.id,
571
703
  severity: rule.severity,
572
704
  file,
573
705
  line,
574
- importPath,
706
+ importPath: reference.source,
707
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
575
708
  message: rule.description,
576
- fingerprint: `${rule.id}|${file}|${importPath}`,
709
+ fingerprint: `${rule.id}|${file}|${reference.source}|${reference.resolved ?? ''}`,
577
710
  });
578
711
  }
579
712
  }
@@ -588,22 +721,25 @@ function scanContracts(root, contracts, changed) {
588
721
  const severity = contract.severity ?? 'error';
589
722
  for (const module of model.modules.filter((item) => (!changed || changed.has(item.path)) && contractAppliesToFile(contract, item.path))) {
590
723
  if (contract.kind === 'import-boundary') {
591
- for (const importPath of module.imports) {
592
- if (contract.mustNotImport?.some((pattern) => patternMatches(importPath, pattern))) {
724
+ for (const reference of moduleImportReferences(module)) {
725
+ if (contract.mustNotImport?.some((pattern) => patternMatches(reference.source, pattern) ||
726
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern)))) {
593
727
  findings.push({
594
728
  ruleId: `contract:${contract.id}`,
595
729
  severity,
596
730
  file: module.path,
597
731
  line: 1,
598
- importPath,
732
+ importPath: reference.source,
733
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
599
734
  message: contract.statement,
600
- fingerprint: `contract-import|${contract.id}|${module.path}|${importPath}`,
735
+ fingerprint: `contract-import|${contract.id}|${module.path}|${reference.source}|${reference.resolved ?? ''}`,
601
736
  });
602
737
  }
603
738
  }
604
739
  if (contract.mustImport !== undefined &&
605
740
  contract.mustImport.length > 0 &&
606
- !module.imports.some((importPath) => contract.mustImport?.some((pattern) => patternMatches(importPath, pattern)))) {
741
+ !moduleImportReferences(module).some((reference) => contract.mustImport?.some((pattern) => patternMatches(reference.source, pattern) ||
742
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))) {
607
743
  findings.push({
608
744
  ruleId: `contract:${contract.id}`,
609
745
  severity,
@@ -629,6 +765,28 @@ function scanContracts(root, contracts, changed) {
629
765
  fingerprint: `contract-required-call|${contract.id}|${module.path}`,
630
766
  });
631
767
  }
768
+ if (contract.kind === 'package-boundary') {
769
+ const sourcePackage = packageForFile(model, module.path);
770
+ if (!contract.fromPackages?.some((selector) => packageSelectorMatches(sourcePackage, selector))) {
771
+ continue;
772
+ }
773
+ for (const reference of moduleImportReferences(module)) {
774
+ const targetPackage = packageForImport(model, reference);
775
+ if (!contract.mustNotImportPackages?.some((selector) => packageSelectorMatches(targetPackage, selector))) {
776
+ continue;
777
+ }
778
+ findings.push({
779
+ ruleId: `contract:${contract.id}`,
780
+ severity,
781
+ file: module.path,
782
+ line: 1,
783
+ importPath: reference.source,
784
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
785
+ message: contract.statement,
786
+ fingerprint: `contract-package|${contract.id}|${module.path}|${reference.source}|${targetPackage ?? ''}`,
787
+ });
788
+ }
789
+ }
632
790
  }
633
791
  }
634
792
  return findings;
@@ -656,6 +814,30 @@ function changedFiles(root) {
656
814
  return undefined;
657
815
  }
658
816
  }
817
+ function changedImpactScope(model, changed) {
818
+ if (!changed)
819
+ return undefined;
820
+ const graph = buildModuleTargetGraph(model.modules);
821
+ const reverse = new Map();
822
+ for (const [from, targets] of graph) {
823
+ for (const target of targets)
824
+ reverse.set(target, [...(reverse.get(target) ?? []), from]);
825
+ }
826
+ const scope = new Set(changed);
827
+ const queue = [...changed];
828
+ while (queue.length > 0) {
829
+ const current = queue.shift();
830
+ if (!current)
831
+ continue;
832
+ for (const dependent of reverse.get(current) ?? []) {
833
+ if (!scope.has(dependent)) {
834
+ scope.add(dependent);
835
+ queue.push(dependent);
836
+ }
837
+ }
838
+ }
839
+ return scope;
840
+ }
659
841
  function architectureInsightFindings(root, changed) {
660
842
  const model = discoverProject(root);
661
843
  const findings = model.insights.cycles
@@ -676,7 +858,9 @@ function architectureInsightFindings(root, changed) {
676
858
  for (const module of model.modules) {
677
859
  if (!clientFiles.has(module.path))
678
860
  continue;
679
- for (const importPath of module.imports.filter((value) => serverOnlyImports.test(value))) {
861
+ for (const importPath of [
862
+ ...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
863
+ ].filter((value) => serverOnlyImports.test(value))) {
680
864
  if (changed && !changed.has(module.path))
681
865
  continue;
682
866
  findings.push({
@@ -715,13 +899,35 @@ function isSafeReviewFile(file) {
715
899
  /\.(?:pem|key|p12|pfx)$/i.test(file));
716
900
  }
717
901
  function isSafeReviewPath(root, file) {
902
+ return isProjectPath(root, file);
903
+ }
904
+ function parseGitChanges(root, args) {
718
905
  try {
719
- const projectRoot = `${realpathSync(root)}${sep}`;
720
- const target = realpathSync(resolve(root, file));
721
- return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
906
+ return execFileSync('git', ['diff', '--name-status', '-M', ...args], {
907
+ cwd: root,
908
+ stdio: 'pipe',
909
+ })
910
+ .toString()
911
+ .trim()
912
+ .split('\n')
913
+ .filter(Boolean)
914
+ .flatMap((line) => {
915
+ const [rawStatus, first, second] = line.split('\t');
916
+ if (!rawStatus || !first || !isSafeReviewFile(first) || !isSafeReviewPath(root, first)) {
917
+ return [];
918
+ }
919
+ const status = rawStatus[0];
920
+ if (status === 'R') {
921
+ if (!second || !isSafeReviewFile(second) || !isSafeReviewPath(root, second))
922
+ return [];
923
+ return [{ path: second, previousPath: first, status: 'renamed' }];
924
+ }
925
+ const mapped = status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified';
926
+ return [{ path: first, status: mapped }];
927
+ });
722
928
  }
723
929
  catch {
724
- return false;
930
+ return [];
725
931
  }
726
932
  }
727
933
  /** @internal Redacts common credential shapes before a diff enters an AI review packet. */
@@ -739,6 +945,9 @@ export function redactSensitiveText(value) {
739
945
  };
740
946
  replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]');
741
947
  replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]');
948
+ replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED JWT]');
949
+ replace(/\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, '[REDACTED URL CREDENTIALS]@');
950
+ replace(/\b(?:AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})\b/g, '[REDACTED TOKEN]');
742
951
  replace(/((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\s*[:=]\s*["']?)[^\s"'`,}]+/gi, '$1[REDACTED]');
743
952
  return { value: redactedValue, redacted, redactionCount };
744
953
  }
@@ -753,6 +962,7 @@ function reviewDiff(root, maxChars, base) {
753
962
  truncated: false,
754
963
  redacted: false,
755
964
  changedFiles: [],
965
+ changes: [],
756
966
  error: `Unsafe Git base ref rejected: ${base}`,
757
967
  };
758
968
  }
@@ -835,6 +1045,10 @@ function reviewDiff(root, maxChars, base) {
835
1045
  truncated: fullDiff.length > maxChars,
836
1046
  redacted: redactedDiff.redacted || redactedUntracked,
837
1047
  changedFiles: [...new Set([...trackedFilesForBase, ...trackedFiles, ...untracked])].sort(),
1048
+ changes: [
1049
+ ...parseGitChanges(root, base ? [`${base}...HEAD`] : ['HEAD']),
1050
+ ...untracked.map((path) => ({ path, status: 'added' })),
1051
+ ].filter((change, index, all) => all.findIndex((candidate) => candidate.path === change.path && candidate.previousPath === change.previousPath) === index),
838
1052
  };
839
1053
  }
840
1054
  catch (error) {
@@ -844,6 +1058,7 @@ function reviewDiff(root, maxChars, base) {
844
1058
  truncated: false,
845
1059
  redacted: false,
846
1060
  changedFiles: [],
1061
+ changes: [],
847
1062
  error: base
848
1063
  ? `Unable to resolve or read Git base ref: ${base}${detail}`
849
1064
  : `Unable to read Git diff.${detail}`,
@@ -851,9 +1066,10 @@ function reviewDiff(root, maxChars, base) {
851
1066
  }
852
1067
  }
853
1068
  export function scanGuard(root, guardConfig, options = {}) {
854
- const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
855
- const files = listSourceFiles(root).filter((file) => !changed || changed.has(file));
856
1069
  const model = discoverProject(root);
1070
+ const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
1071
+ const scope = changedImpactScope(model, changed);
1072
+ const files = listSourceFiles(root).filter((file) => !scope || scope.has(file));
857
1073
  const modules = new Map(model.modules.map((module) => [module.path, module]));
858
1074
  const astProject = (() => {
859
1075
  try {
@@ -865,8 +1081,8 @@ export function scanGuard(root, guardConfig, options = {}) {
865
1081
  })();
866
1082
  const allFindings = [
867
1083
  ...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
868
- ...scanContracts(root, guardConfig.contracts ?? [], changed),
869
- ...(options.includeArchitectureInsights ? architectureInsightFindings(root, changed) : []),
1084
+ ...scanContracts(root, guardConfig.contracts ?? [], scope),
1085
+ ...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
870
1086
  ];
871
1087
  const baseline = options.baseline ?? new Set();
872
1088
  const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
@@ -886,11 +1102,13 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
886
1102
  baseline,
887
1103
  includeArchitectureInsights: true,
888
1104
  });
1105
+ const impact = analyzeProjectImpact(project, diff.changedFiles, guardConfig.contracts ?? []);
889
1106
  return {
890
1107
  version: 1,
891
1108
  outcome: classifyGuardOutcome({ errors: diff.error ? 1 : 0, needsReview: !diff.error }),
892
1109
  ...(base ? { diffBase: base } : {}),
893
1110
  changedFiles: diff.changedFiles,
1111
+ changes: diff.changes,
894
1112
  diff: diff.diff,
895
1113
  truncated: diff.truncated,
896
1114
  redacted: diff.redacted,
@@ -899,6 +1117,7 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
899
1117
  contracts: guardConfig.contracts ?? [],
900
1118
  ...(requirement ? { requirement } : {}),
901
1119
  deterministicFindings: report.findings,
1120
+ impact,
902
1121
  reviewInstructions: [
903
1122
  ...(requirement
904
1123
  ? [
@@ -933,4 +1152,4 @@ export function initializeGuard(root, options = {}) {
933
1152
  writeBaseline(root, initialReport.findings, projectModel);
934
1153
  return { config: guardConfig, report: { ...initialReport, findings: [] } };
935
1154
  }
936
- export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
1155
+ export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };
@@ -15,6 +15,12 @@ export interface GuardHistoryDiff {
15
15
  removedDependencies: string[];
16
16
  addedCapabilities: string[];
17
17
  removedCapabilities: string[];
18
+ addedModules: string[];
19
+ removedModules: string[];
20
+ addedDependencyEdges: string[];
21
+ removedDependencyEdges: string[];
22
+ addedLayerEdges: string[];
23
+ removedLayerEdges: string[];
18
24
  cycles: {
19
25
  from: number;
20
26
  to: number;
@@ -1,6 +1,19 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import { GUARD_HISTORY_DIR } from '../guard.js';
4
+ function snapshotInsights(model) {
5
+ return model.insights ?? {};
6
+ }
7
+ function dependencyEdges(model) {
8
+ return (snapshotInsights(model).dependencyEdges ?? [])
9
+ .map((edge) => `${edge.from} -> ${edge.to}`)
10
+ .sort();
11
+ }
12
+ function layerEdges(model) {
13
+ return (snapshotInsights(model).layerEdges ?? [])
14
+ .map((edge) => `${edge.from} -> ${edge.to}`)
15
+ .sort();
16
+ }
4
17
  function readSnapshot(root, revision) {
5
18
  if (!/^[A-Za-z0-9._-]+$/.test(revision))
6
19
  return undefined;
@@ -29,7 +42,7 @@ export function listGuardSnapshots(root) {
29
42
  files: model.files.length,
30
43
  modules: model.modules.length,
31
44
  capabilities: Object.keys(model.capabilities).sort(),
32
- cycles: model.insights.cycles.length,
45
+ cycles: snapshotInsights(model).cycles?.length ?? 0,
33
46
  },
34
47
  ]
35
48
  : [];
@@ -51,6 +64,8 @@ export function diffGuardSnapshots(root, from, to) {
51
64
  ...after.project.dependencies,
52
65
  ...after.project.devDependencies,
53
66
  });
67
+ const beforeModules = before.modules.map((module) => module.path);
68
+ const afterModules = after.modules.map((module) => module.path);
54
69
  return {
55
70
  from,
56
71
  to,
@@ -60,6 +75,15 @@ export function diffGuardSnapshots(root, from, to) {
60
75
  removedDependencies: difference(afterDependencies, beforeDependencies),
61
76
  addedCapabilities: difference(Object.keys(before.capabilities), Object.keys(after.capabilities)),
62
77
  removedCapabilities: difference(Object.keys(after.capabilities), Object.keys(before.capabilities)),
63
- cycles: { from: before.insights.cycles.length, to: after.insights.cycles.length },
78
+ addedModules: difference(beforeModules, afterModules),
79
+ removedModules: difference(afterModules, beforeModules),
80
+ addedDependencyEdges: difference(dependencyEdges(before), dependencyEdges(after)),
81
+ removedDependencyEdges: difference(dependencyEdges(after), dependencyEdges(before)),
82
+ addedLayerEdges: difference(layerEdges(before), layerEdges(after)),
83
+ removedLayerEdges: difference(layerEdges(after), layerEdges(before)),
84
+ cycles: {
85
+ from: snapshotInsights(before).cycles?.length ?? 0,
86
+ to: snapshotInsights(after).cycles?.length ?? 0,
87
+ },
64
88
  };
65
89
  }
@@ -30,6 +30,7 @@ export declare const guardContractSchema: z.ZodObject<{
30
30
  kind: z.ZodOptional<z.ZodEnum<{
31
31
  guidance: "guidance";
32
32
  "import-boundary": "import-boundary";
33
+ "package-boundary": "package-boundary";
33
34
  "required-call": "required-call";
34
35
  }>>;
35
36
  severity: z.ZodOptional<z.ZodEnum<{
@@ -45,6 +46,8 @@ export declare const guardContractSchema: z.ZodObject<{
45
46
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
46
47
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
47
48
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
49
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
50
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
48
51
  status: z.ZodOptional<z.ZodEnum<{
49
52
  active: "active";
50
53
  proposed: "proposed";
@@ -89,6 +92,7 @@ export declare const guardConfigSchema: z.ZodObject<{
89
92
  kind: z.ZodOptional<z.ZodEnum<{
90
93
  guidance: "guidance";
91
94
  "import-boundary": "import-boundary";
95
+ "package-boundary": "package-boundary";
92
96
  "required-call": "required-call";
93
97
  }>>;
94
98
  severity: z.ZodOptional<z.ZodEnum<{
@@ -104,6 +108,8 @@ export declare const guardConfigSchema: z.ZodObject<{
104
108
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
105
109
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
106
110
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
112
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
107
113
  status: z.ZodOptional<z.ZodEnum<{
108
114
  active: "active";
109
115
  proposed: "proposed";
@@ -124,6 +130,7 @@ export declare const guardContractsFileSchema: z.ZodObject<{
124
130
  kind: z.ZodOptional<z.ZodEnum<{
125
131
  guidance: "guidance";
126
132
  "import-boundary": "import-boundary";
133
+ "package-boundary": "package-boundary";
127
134
  "required-call": "required-call";
128
135
  }>>;
129
136
  severity: z.ZodOptional<z.ZodEnum<{
@@ -139,6 +146,8 @@ export declare const guardContractsFileSchema: z.ZodObject<{
139
146
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
140
147
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
141
148
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
149
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
150
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
142
151
  status: z.ZodOptional<z.ZodEnum<{
143
152
  active: "active";
144
153
  proposed: "proposed";
@@ -204,6 +213,7 @@ export declare const guardProposalSchema: z.ZodObject<{
204
213
  kind: z.ZodOptional<z.ZodEnum<{
205
214
  guidance: "guidance";
206
215
  "import-boundary": "import-boundary";
216
+ "package-boundary": "package-boundary";
207
217
  "required-call": "required-call";
208
218
  }>>;
209
219
  severity: z.ZodOptional<z.ZodEnum<{
@@ -219,6 +229,8 @@ export declare const guardProposalSchema: z.ZodObject<{
219
229
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
220
230
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
221
231
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
232
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
233
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
222
234
  status: z.ZodOptional<z.ZodEnum<{
223
235
  active: "active";
224
236
  proposed: "proposed";