@codapult/guard 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/codapult/codapult-guard/actions/workflows/ci.yml/badge.svg)](https://github.com/codapult/codapult-guard/actions/workflows/ci.yml)
4
4
  [![Fixtures](https://github.com/codapult/codapult-guard/actions/workflows/guard-fixtures.yml/badge.svg)](https://github.com/codapult/codapult-guard/actions/workflows/guard-fixtures.yml)
5
+ [![npm](https://img.shields.io/npm/v/@codapult/guard?logo=npm)](https://www.npmjs.com/package/@codapult/guard)
5
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
7
  [![Node.js >=20.19](https://img.shields.io/badge/node-%3E%3D20.19-339933.svg?logo=node.js&logoColor=white)](package.json)
7
8
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-3178C6.svg?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
@@ -11,8 +12,43 @@
11
12
  `@codapult/guard` is a local-first, model-agnostic architecture guard for JavaScript and
12
13
  TypeScript projects.
13
14
 
14
- It learns what already exists, records the project’s architectural memory, and protects future
15
- changes from introducing regressions.
15
+ It learns what already exists, records the project’s architectural memory, and can protect future
16
+ changes from introducing regressions covered by the project’s approved policy.
17
+
18
+ ### See the value in one change
19
+
20
+ Suppose a SaaS project has an established boundary:
21
+
22
+ ```text
23
+ Client → Server Action → Service → Repository → Database
24
+ ```
25
+
26
+ An AI agent can still produce code that is valid TypeScript but crosses that boundary:
27
+
28
+ ```text
29
+ Client → Database
30
+ ```
31
+
32
+ After the project has explicitly approved a matching import rule or deterministic contract, Guard
33
+ checks the changed files and reports matching violations deterministically:
34
+
35
+ ```text
36
+ Codapult Guard (changed files)
37
+ Scanned 1 source file(s); suppressed 12 baseline finding(s).
38
+
39
+ src/app/settings/page.tsx:1 [client-no-persistence-import]
40
+ Client modules should not import persistence-layer modules.
41
+ import: @/lib/db/client
42
+
43
+ ✗ 1 issue(s) found
44
+ Exit code: 1
45
+ ```
46
+
47
+ The file path, rule ID, message, and counts depend on the project. This is the actual CLI output
48
+ shape: Guard does not infer that every `Client → Database` edge is forbidden, and it does not
49
+ activate proposals silently. The team approves the relevant policy first; then existing findings
50
+ can be baselined while newly matching violations fail the gate and give the agent bounded evidence
51
+ to repair.
16
52
 
17
53
  > **Guard does not tell every project to use the same architecture.**
18
54
  > It discovers the architecture that is already there, then lets the team decide what becomes policy.
@@ -164,6 +200,12 @@ init once → edit → check --changed → review → verify → commit / merge
164
200
  contract validation.
165
201
  5. `audit` inspects the complete current state, including findings accepted by the baseline.
166
202
 
203
+ For a focused change explanation, use `codapult-guard impact <file...>` or the equivalent
204
+ `codapult_guard_impact` MCP tool. It reports both directions of the dependency graph: what the
205
+ changed module uses and which transitive callers may be affected. `review` includes the same
206
+ impact packet plus typed Git changes (`added`, `modified`, `deleted`, and `renamed`) for the AI
207
+ host.
208
+
167
209
  Guard state is stored in `.codapult/guard/`:
168
210
 
169
211
  ```text
@@ -194,6 +236,25 @@ Start the standalone MCP server over stdio:
194
236
  pnpm exec codapult-guard mcp-server
195
237
  ```
196
238
 
239
+ Register that command in the MCP client from the project root. For example, Cursor can use
240
+ `.cursor/mcp.json`:
241
+
242
+ ```json
243
+ {
244
+ "mcpServers": {
245
+ "codapult-guard": {
246
+ "command": "pnpm",
247
+ "args": ["exec", "codapult-guard", "mcp-server"],
248
+ "cwd": "."
249
+ }
250
+ }
251
+ }
252
+ ```
253
+
254
+ For clients that accept a generic stdio server, use the same command and set its working
255
+ directory to the project root. Guard reads local files only; no API key or model provider is
256
+ required. Host-specific examples are in [`docs/integrations/`](docs/integrations/).
257
+
197
258
  Recommended agent loop:
198
259
 
199
260
  ```text
@@ -225,19 +286,20 @@ real project shapes.
225
286
 
226
287
  ## CLI surface
227
288
 
228
- | Command | Purpose |
229
- | -------------------------- | --------------------------------------------------------------- |
230
- | `init` | Create project memory and the initial baseline. |
231
- | `analyze` | Refresh facts without changing policy or baseline. |
232
- | `propose` | Generate evidence-based rule and contract proposals. |
233
- | `check --changed` | Enforce active policy on changed and untracked files. |
234
- | `audit` | Scan the complete current project, including baseline findings. |
235
- | `review` | Create a bounded semantic-review packet for an AI host. |
236
- | `verify` | Run the configured completion gate. |
237
- | `doctor` | Diagnose invalid or missing Guard artifacts. |
238
- | `history` / `history-diff` | Inspect project model evolution. |
239
- | `rules` / `contracts` | Approve or reject proposed policy. |
240
- | `baseline` | Review or intentionally accept existing findings. |
289
+ | Command | Purpose |
290
+ | -------------------------- | ------------------------------------------------------------------------------------------- |
291
+ | `init` | Create project memory and the initial baseline. |
292
+ | `analyze` | Refresh facts without changing policy or baseline. |
293
+ | `propose` | Generate evidence-based rule and contract proposals. |
294
+ | `check --changed` | Enforce active policy on changed and untracked files. |
295
+ | `audit` | Scan the complete current project, including baseline findings. |
296
+ | `review` | Create a bounded semantic-review packet for an AI host. |
297
+ | `verify` | Run the configured completion gate. |
298
+ | `doctor` | Diagnose invalid or missing Guard artifacts. |
299
+ | `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
300
+ | `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
301
+ | `rules` / `contracts` | Approve or reject proposed policy. |
302
+ | `baseline` | Review or intentionally accept existing findings. |
241
303
 
242
304
  Run `pnpm exec codapult-guard <command> --help` for command-specific options.
243
305
 
@@ -13,6 +13,9 @@ export declare function guardDoctorCommand(options?: {
13
13
  json?: boolean;
14
14
  }): void;
15
15
  export declare function guardHistoryCommand(): void;
16
+ export declare function guardImpactCommand(files: string[], options?: {
17
+ json?: boolean;
18
+ }): void;
16
19
  export declare function guardHistoryDiffCommand(from: string, to: string, options?: {
17
20
  json?: boolean;
18
21
  }): void;
@@ -7,6 +7,7 @@ import { diffGuardSnapshots, listGuardSnapshots } from '../../core/history/histo
7
7
  import { guardAgentTargets, installGuardAgentInstructions, installGuardAgentTargets, } from '../../adapters/agents/agent-integration.js';
8
8
  import { dim, fail, heading, info, success, warn } from '../ui.js';
9
9
  import { guardFindingsToSarif } from '../../core/output/sarif.js';
10
+ import { analyzeProjectImpact } from '../../core/analysis/impact.js';
10
11
  function renderFindings(findings) {
11
12
  for (const finding of findings) {
12
13
  const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
@@ -166,6 +167,26 @@ export function guardHistoryCommand() {
166
167
  info(`${snapshot.revision}: ${snapshot.files} files, ${snapshot.modules} modules, ${snapshot.cycles} cycles`);
167
168
  }
168
169
  }
170
+ export function guardImpactCommand(files, options = {}) {
171
+ const root = getRoot();
172
+ if (files.length === 0) {
173
+ fail('Specify at least one project-relative file.');
174
+ process.exitCode = 1;
175
+ return;
176
+ }
177
+ const result = analyzeProjectImpact(discoverProject(root), files, loadGuardConfig(root)?.contracts ?? []);
178
+ if (options.json) {
179
+ console.log(JSON.stringify({ status: 'ok', ...result }, null, 2));
180
+ return;
181
+ }
182
+ heading('Codapult Guard Impact');
183
+ info(`Requested: ${result.requestedFiles.join(', ')}`);
184
+ info(`Direct modules: ${result.directModules.join(', ') || 'none'}`);
185
+ info(`Dependencies: ${result.dependencies.join(', ') || 'none'}`);
186
+ info(`Transitive dependents: ${result.dependents.join(', ') || 'none'}`);
187
+ info(`Capabilities: ${result.capabilities.join(', ') || 'none'}`);
188
+ info(`Relevant contracts: ${result.relevantContracts.map((contract) => contract.id).join(', ') || 'none'}`);
189
+ }
169
190
  export function guardHistoryDiffCommand(from, to, options = {}) {
170
191
  const diff = diffGuardSnapshots(getRoot(), from, to);
171
192
  if (!diff) {
@@ -181,6 +202,9 @@ export function guardHistoryDiffCommand(from, to, options = {}) {
181
202
  info(`Removed files: ${diff.removedFiles.length}`);
182
203
  info(`Added dependencies: ${diff.addedDependencies.join(', ') || 'none'}`);
183
204
  info(`Removed dependencies: ${diff.removedDependencies.join(', ') || 'none'}`);
205
+ info(`Modules: +${diff.addedModules.length} / -${diff.removedModules.length}`);
206
+ info(`Dependency edges: +${diff.addedDependencyEdges.length} / -${diff.removedDependencyEdges.length}`);
207
+ info(`Layer edges: +${diff.addedLayerEdges.length} / -${diff.removedLayerEdges.length}`);
184
208
  info(`Capabilities: +${diff.addedCapabilities.join(', ') || 'none'} / -${diff.removedCapabilities.join(', ') || 'none'}`);
185
209
  info(`Cycles: ${diff.cycles.from} → ${diff.cycles.to}`);
186
210
  }
package/dist/cli/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import pc from 'picocolors';
4
- import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardInitCommand, guardInstallAgentCommand, guardProposeCommand, guardReviewCommand, guardRulesApproveCommand, guardVerifyCommand, } from './commands/guard.js';
4
+ import { guardAnalyzeCommand, guardAuditCommand, guardBaselineCommand, guardCheckCommand, guardContractsApproveCommand, guardContractsRejectCommand, guardDoctorCommand, guardHistoryCommand, guardHistoryDiffCommand, guardImpactCommand, guardInitCommand, guardInstallAgentCommand, guardProposeCommand, guardReviewCommand, guardRulesApproveCommand, guardVerifyCommand, } from './commands/guard.js';
5
5
  import { config } from '../core/config.js';
6
6
  const program = new Command()
7
7
  .name(config.commandName)
@@ -21,6 +21,7 @@ guard.command('install-agent [target]').option('--json').action(guardInstallAgen
21
21
  guard.command('doctor').option('--json').action(guardDoctorCommand);
22
22
  guard.command('history').action(guardHistoryCommand);
23
23
  guard.command('history-diff <from> <to>').option('--json').action(guardHistoryDiffCommand);
24
+ guard.command('impact <files...>').option('--json').action(guardImpactCommand);
24
25
  guard
25
26
  .command('check')
26
27
  .option('--changed')
@@ -0,0 +1,22 @@
1
+ import type { GuardContract } from '../guard.js';
2
+ import { type ProjectModel } from '../discovery/discovery.js';
3
+ export interface GuardImpactAnalysis {
4
+ requestedFiles: string[];
5
+ directModules: string[];
6
+ dependencies: string[];
7
+ dependents: string[];
8
+ affectedModules: string[];
9
+ impactPaths: ProjectModel['insights']['impactPaths'];
10
+ capabilities: string[];
11
+ relevantContracts: GuardContract[];
12
+ dependencyEdges: ProjectModel['insights']['dependencyEdges'];
13
+ layerEdges: ProjectModel['insights']['layerEdges'];
14
+ }
15
+ /**
16
+ * Computes the blast radius of a change from the discovered graph.
17
+ *
18
+ * The graph direction is importer -> imported module. Therefore `dependencies` are what the
19
+ * changed code relies on, while `dependents` are callers that can regress when the changed
20
+ * code changes. Both directions matter for an AI review packet.
21
+ */
22
+ export declare function analyzeProjectImpact(model: ProjectModel, files: Iterable<string>, contracts?: GuardContract[]): GuardImpactAnalysis;
@@ -0,0 +1,73 @@
1
+ import { buildModuleTargetGraph } from '../discovery/discovery.js';
2
+ function matchesScope(path, scope) {
3
+ return path === scope || path.startsWith(`${scope.replace(/\\/g, '/')}/`);
4
+ }
5
+ function walk(graph, starts) {
6
+ const visited = new Set();
7
+ const queue = [...starts];
8
+ while (queue.length > 0) {
9
+ const current = queue.shift();
10
+ if (!current || visited.has(current))
11
+ continue;
12
+ visited.add(current);
13
+ for (const next of graph.get(current) ?? []) {
14
+ if (!visited.has(next))
15
+ queue.push(next);
16
+ }
17
+ }
18
+ return visited;
19
+ }
20
+ /**
21
+ * Computes the blast radius of a change from the discovered graph.
22
+ *
23
+ * The graph direction is importer -> imported module. Therefore `dependencies` are what the
24
+ * changed code relies on, while `dependents` are callers that can regress when the changed
25
+ * code changes. Both directions matter for an AI review packet.
26
+ */
27
+ export function analyzeProjectImpact(model, files, contracts = []) {
28
+ const requestedFiles = [...new Set([...files].map((file) => file.replace(/\\/g, '/')))].sort();
29
+ const modulePaths = new Set(model.modules.map((module) => module.path));
30
+ const directModules = requestedFiles.filter((file) => modulePaths.has(file));
31
+ const graph = buildModuleTargetGraph(model.modules);
32
+ const reverse = new Map();
33
+ for (const [from, targets] of graph) {
34
+ for (const to of targets) {
35
+ const callers = reverse.get(to) ?? [];
36
+ callers.push(from);
37
+ reverse.set(to, callers);
38
+ }
39
+ }
40
+ const dependencies = walk(graph, directModules);
41
+ const dependencyList = [...dependencies].filter((path) => !directModules.includes(path)).sort();
42
+ const dependents = walk(reverse, directModules);
43
+ const dependentList = [...dependents].filter((path) => !directModules.includes(path)).sort();
44
+ const affected = new Set([...directModules, ...dependencyList, ...dependentList]);
45
+ const impactPaths = model.insights.impactPaths.filter((impact) => impact.entrypoint &&
46
+ (affected.has(impact.entrypoint) || impact.files.some((file) => affected.has(file))));
47
+ const capabilities = Object.entries(model.capabilities)
48
+ .filter(([, signal]) => signal.files.some((file) => affected.has(file)))
49
+ .map(([id]) => id)
50
+ .sort();
51
+ const relevantContracts = contracts.filter((contract) => {
52
+ const scopes = [...(contract.scope ?? []), ...(contract.entrypoints ?? [])];
53
+ return (scopes.length === 0 ||
54
+ scopes.some((scope) => [...affected].some((file) => matchesScope(file, scope))));
55
+ });
56
+ const dependencyEdges = model.insights.dependencyEdges.filter((edge) => affected.has(edge.from) || affected.has(edge.to));
57
+ const affectedLayers = new Set(Object.entries(model.insights.layers)
58
+ .filter(([, paths]) => paths.some((path) => affected.has(path)))
59
+ .map(([layer]) => layer));
60
+ const layerEdges = model.insights.layerEdges.filter((edge) => affectedLayers.has(edge.from) || affectedLayers.has(edge.to));
61
+ return {
62
+ requestedFiles,
63
+ directModules,
64
+ dependencies: dependencyList,
65
+ dependents: dependentList,
66
+ affectedModules: [...affected].sort(),
67
+ impactPaths,
68
+ capabilities,
69
+ relevantContracts,
70
+ dependencyEdges,
71
+ layerEdges,
72
+ };
73
+ }
@@ -123,6 +123,13 @@ export interface DiscoveryMetrics {
123
123
  reusedModules: number;
124
124
  }
125
125
  export declare function findGuardRoot(from?: string): string;
126
+ /** Build the internal module graph from AST resolution plus conservative fallback resolution.
127
+ *
128
+ * TypeScript resolution is authoritative when available, but it is not complete for every
129
+ * JavaScript/configuration shape. The fallback must be additive: using it only when there are
130
+ * no resolved imports silently drops unresolved edges from mixed projects.
131
+ */
132
+ export declare function buildModuleTargetGraph(modules: ModuleRecord[]): Map<string, string[]>;
126
133
  export declare function discoverProject(root: string, options?: {
127
134
  persistCache?: boolean;
128
135
  }): ProjectModel;
@@ -337,16 +337,23 @@ function layerForPath(path) {
337
337
  }
338
338
  return 'other';
339
339
  }
340
- function buildModuleTargetGraph(modules) {
340
+ /** Build the internal module graph from AST resolution plus conservative fallback resolution.
341
+ *
342
+ * TypeScript resolution is authoritative when available, but it is not complete for every
343
+ * JavaScript/configuration shape. The fallback must be additive: using it only when there are
344
+ * no resolved imports silently drops unresolved edges from mixed projects.
345
+ */
346
+ export function buildModuleTargetGraph(modules) {
341
347
  const moduleSet = new Set(modules.map((candidate) => candidate.path));
342
- return new Map(modules.map((module) => [
343
- module.path,
344
- module.resolvedImports.length > 0
345
- ? module.resolvedImports
346
- : module.imports
347
- .map((importPath) => resolveInternalImport(module.path, importPath, moduleSet))
348
- .filter((path) => path !== undefined),
349
- ]));
348
+ return new Map(modules.map((module) => {
349
+ const targets = new Set(module.resolvedImports.filter((path) => moduleSet.has(path)));
350
+ for (const importPath of [...module.imports, ...module.dynamicImports]) {
351
+ const target = resolveInternalImport(module.path, importPath, moduleSet);
352
+ if (target)
353
+ targets.add(target);
354
+ }
355
+ return [module.path, [...targets].sort()];
356
+ }));
350
357
  }
351
358
  function findCycles(modules) {
352
359
  const graph = buildModuleTargetGraph(modules);
@@ -1,5 +1,6 @@
1
1
  import type { ProjectModel } from './discovery/discovery.js';
2
2
  import { discoverProject, discoverProjectWithMetrics, findGuardRoot } from './discovery/discovery.js';
3
+ import { type GuardImpactAnalysis } from './analysis/impact.js';
3
4
  export type GuardSeverity = 'error' | 'warning' | 'info';
4
5
  export type GuardOutcomeStatus = 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
5
6
  export declare function classifyGuardOutcome(input: {
@@ -91,6 +92,11 @@ export interface GuardFinding {
91
92
  message: string;
92
93
  fingerprint: string;
93
94
  }
95
+ export interface GuardFileChange {
96
+ path: string;
97
+ status: 'added' | 'modified' | 'deleted' | 'renamed';
98
+ previousPath?: string;
99
+ }
94
100
  export interface GuardContractIssue {
95
101
  contractId: string;
96
102
  field: 'scope' | 'reference' | 'definition';
@@ -114,6 +120,7 @@ export interface GuardReviewPacket {
114
120
  outcome: GuardOutcomeStatus;
115
121
  diffBase?: string;
116
122
  changedFiles: string[];
123
+ changes: GuardFileChange[];
117
124
  diff: string;
118
125
  truncated: boolean;
119
126
  redacted: boolean;
@@ -122,6 +129,7 @@ export interface GuardReviewPacket {
122
129
  contracts: GuardContract[];
123
130
  requirement?: string;
124
131
  deterministicFindings: GuardFinding[];
132
+ impact: GuardImpactAnalysis;
125
133
  reviewInstructions: string[];
126
134
  }
127
135
  export declare const GUARD_DIR: string;
@@ -1,11 +1,12 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, writeFileSync, } from 'node:fs';
4
- import { extname, relative, resolve, sep } from 'node:path';
4
+ import { dirname, extname, relative, resolve, sep } from 'node:path';
5
5
  import { Project, SyntaxKind } from 'ts-morph';
6
6
  import { discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
7
7
  import { config } from './config.js';
8
8
  import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
9
+ import { analyzeProjectImpact } from './analysis/impact.js';
9
10
  import { guardAgentConfigSchema, guardConfigSchema, guardContractsFileSchema, guardProposalSchema, } from './policy/schemas.js';
10
11
  export function classifyGuardOutcome(input) {
11
12
  if (input.configured === false)
@@ -715,15 +716,55 @@ function isSafeReviewFile(file) {
715
716
  /\.(?:pem|key|p12|pfx)$/i.test(file));
716
717
  }
717
718
  function isSafeReviewPath(root, file) {
719
+ const normalizedFile = file.replace(/\\/g, '/');
720
+ if (normalizedFile.startsWith('/') || normalizedFile.split('/').includes('..'))
721
+ return false;
718
722
  try {
719
723
  const projectRoot = `${realpathSync(root)}${sep}`;
720
- const target = realpathSync(resolve(root, file));
724
+ const targetPath = resolve(root, normalizedFile);
725
+ let target;
726
+ try {
727
+ target = realpathSync(targetPath);
728
+ }
729
+ catch {
730
+ // Deleted files have no realpath. Validate their nearest existing parent instead.
731
+ target = realpathSync(dirname(targetPath));
732
+ }
721
733
  return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
722
734
  }
723
735
  catch {
724
736
  return false;
725
737
  }
726
738
  }
739
+ function parseGitChanges(root, args) {
740
+ try {
741
+ return execFileSync('git', ['diff', '--name-status', '-M', ...args], {
742
+ cwd: root,
743
+ stdio: 'pipe',
744
+ })
745
+ .toString()
746
+ .trim()
747
+ .split('\n')
748
+ .filter(Boolean)
749
+ .flatMap((line) => {
750
+ const [rawStatus, first, second] = line.split('\t');
751
+ if (!rawStatus || !first || !isSafeReviewFile(first) || !isSafeReviewPath(root, first)) {
752
+ return [];
753
+ }
754
+ const status = rawStatus[0];
755
+ if (status === 'R') {
756
+ if (!second || !isSafeReviewFile(second) || !isSafeReviewPath(root, second))
757
+ return [];
758
+ return [{ path: second, previousPath: first, status: 'renamed' }];
759
+ }
760
+ const mapped = status === 'A' ? 'added' : status === 'D' ? 'deleted' : 'modified';
761
+ return [{ path: first, status: mapped }];
762
+ });
763
+ }
764
+ catch {
765
+ return [];
766
+ }
767
+ }
727
768
  /** @internal Redacts common credential shapes before a diff enters an AI review packet. */
728
769
  export function redactSensitiveText(value) {
729
770
  let redacted = false;
@@ -753,6 +794,7 @@ function reviewDiff(root, maxChars, base) {
753
794
  truncated: false,
754
795
  redacted: false,
755
796
  changedFiles: [],
797
+ changes: [],
756
798
  error: `Unsafe Git base ref rejected: ${base}`,
757
799
  };
758
800
  }
@@ -835,6 +877,10 @@ function reviewDiff(root, maxChars, base) {
835
877
  truncated: fullDiff.length > maxChars,
836
878
  redacted: redactedDiff.redacted || redactedUntracked,
837
879
  changedFiles: [...new Set([...trackedFilesForBase, ...trackedFiles, ...untracked])].sort(),
880
+ changes: [
881
+ ...parseGitChanges(root, base ? [`${base}...HEAD`] : ['HEAD']),
882
+ ...untracked.map((path) => ({ path, status: 'added' })),
883
+ ].filter((change, index, all) => all.findIndex((candidate) => candidate.path === change.path && candidate.previousPath === change.previousPath) === index),
838
884
  };
839
885
  }
840
886
  catch (error) {
@@ -844,6 +890,7 @@ function reviewDiff(root, maxChars, base) {
844
890
  truncated: false,
845
891
  redacted: false,
846
892
  changedFiles: [],
893
+ changes: [],
847
894
  error: base
848
895
  ? `Unable to resolve or read Git base ref: ${base}${detail}`
849
896
  : `Unable to read Git diff.${detail}`,
@@ -886,11 +933,13 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
886
933
  baseline,
887
934
  includeArchitectureInsights: true,
888
935
  });
936
+ const impact = analyzeProjectImpact(project, diff.changedFiles, guardConfig.contracts ?? []);
889
937
  return {
890
938
  version: 1,
891
939
  outcome: classifyGuardOutcome({ errors: diff.error ? 1 : 0, needsReview: !diff.error }),
892
940
  ...(base ? { diffBase: base } : {}),
893
941
  changedFiles: diff.changedFiles,
942
+ changes: diff.changes,
894
943
  diff: diff.diff,
895
944
  truncated: diff.truncated,
896
945
  redacted: diff.redacted,
@@ -899,6 +948,7 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
899
948
  contracts: guardConfig.contracts ?? [],
900
949
  ...(requirement ? { requirement } : {}),
901
950
  deterministicFindings: report.findings,
951
+ impact,
902
952
  reviewInstructions: [
903
953
  ...(requirement
904
954
  ? [
@@ -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
  }
package/dist/index.d.ts CHANGED
@@ -7,4 +7,5 @@ export * from './core/history/history.js';
7
7
  export * from './core/output/sarif.js';
8
8
  export * from './core/analysis/doctor.js';
9
9
  export * from './core/analysis/packs.js';
10
+ export * from './core/analysis/impact.js';
10
11
  export * from './adapters/project-checks.js';
package/dist/index.js CHANGED
@@ -6,4 +6,5 @@ export * from './core/history/history.js';
6
6
  export * from './core/output/sarif.js';
7
7
  export * from './core/analysis/doctor.js';
8
8
  export * from './core/analysis/packs.js';
9
+ export * from './core/analysis/impact.js';
9
10
  export * from './adapters/project-checks.js';
@@ -1,4 +1,5 @@
1
1
  import { buildArchitectureMemory, buildConventionsMemory, buildGeneratedGuardConfig, buildGuardProposals, buildGuardReviewPacket, discoverProject, discoverProjectWithMetrics, findGuardRoot, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, loadBaseline, loadGuardArtifact, loadGuardConfig, loadGuardAgentConfig, loadGuardProposals, getGuardProposalFreshness, loadProjectModel, writeGuardProposals, initializeGuard, GuardAlreadyInitializedError, recordGuardProposalDecision, writeGuardConfig, scanGuard, validateGuardContracts, classifyGuardOutcome, } from '../../core/guard.js';
2
+ import { analyzeProjectImpact } from '../../core/analysis/impact.js';
2
3
  import { z } from 'zod';
3
4
  import { runGuardVerification } from '../../core/verification/verify.js';
4
5
  const rootSchema = z.string().trim().min(1).optional().describe('Project path or workspace root.');
@@ -330,20 +331,10 @@ export function registerGuardTools(server) {
330
331
  }, ({ root: requestedRoot, files }) => {
331
332
  const root = getGuardRoot(requestedRoot);
332
333
  const model = discoverProject(root);
333
- const changed = new Set(files);
334
- const paths = model.insights.impactPaths.filter((impact) => impact.files.some((file) => changed.has(file)) || changed.has(impact.entrypoint));
335
- const modules = model.modules.filter((module) => changed.has(module.path) || module.resolvedImports.some((file) => changed.has(file)));
336
- const capabilities = Object.entries(model.capabilities)
337
- .filter(([, signal]) => signal.files.some((file) => changed.has(file)))
338
- .map(([id]) => id);
339
- const contracts = (loadGuardConfig(root)?.contracts ?? []).filter((contract) => [...(contract.scope ?? []), ...(contract.entrypoints ?? [])].some((scope) => [...changed].some((file) => file === scope || file.startsWith(`${scope}/`))));
334
+ const impact = analyzeProjectImpact(model, files, loadGuardConfig(root)?.contracts ?? []);
340
335
  return jsonToolResult({
341
336
  status: 'ok',
342
- files,
343
- affectedModules: modules.map((module) => module.path),
344
- impactPaths: paths,
345
- capabilities,
346
- relevantContracts: contracts,
337
+ ...impact,
347
338
  });
348
339
  });
349
340
  server.registerTool('codapult_guard_explain', {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@codapult/guard",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local-first architecture guardrails for JavaScript and TypeScript projects, with first-class support for Next.js SaaS and AI-assisted development",
5
5
  "license": "MIT",
6
- "packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621",
6
+ "packageManager": "pnpm@12.4.2+sha512.08adc6613180275c7c9edada39dcf08c9c61ad4e7eaf330a4f3461f102b0f907423454d117f98e72d47fef0616070644d7bffc973a6a57f5090a6d7c368b07c9",
7
7
  "engines": {
8
8
  "node": ">=20.19.0"
9
9
  },
@@ -95,23 +95,23 @@
95
95
  "semver": "^7.8.5",
96
96
  "ts-morph": "^28.0.0",
97
97
  "typescript": "npm:@typescript/typescript6@^6.0.2",
98
- "zod": "^4.4.3"
98
+ "zod": "^4.6.5"
99
99
  },
100
100
  "devDependencies": {
101
101
  "@js-toolkit/config-utils": "^1.0.1",
102
- "@js-toolkit/eslint-config": "^1.1.4",
102
+ "@js-toolkit/eslint-config": "^1.3.0",
103
103
  "@js-toolkit/prettier-config": "^1.0.1",
104
104
  "@js-toolkit/tsconfig": "^1.0.1",
105
- "@release-it/conventional-changelog": "^12.0.0",
106
- "@types/node": "^26.2.0",
105
+ "@release-it/conventional-changelog": "^12.0.2",
106
+ "@types/node": "^26.6.1",
107
107
  "@typescript/native": "npm:typescript@^7.0.2",
108
- "@vitest/coverage-v8": "^4.1.11",
109
- "eslint": "^9.39.4",
108
+ "@vitest/coverage-v8": "^5.0.1",
109
+ "eslint": "^10.10.0",
110
110
  "eslint-config-prettier": "^10.1.8",
111
111
  "eslint-plugin-prettier": "^5.5.6",
112
- "prettier": "^3.9.6",
113
- "release-it": "^21.0.2",
114
- "typescript-eslint": "^8.67.0",
115
- "vitest": "^4.1.11"
112
+ "prettier": "^3.9.7",
113
+ "release-it": "^21.0.3",
114
+ "typescript-eslint": "^8.70.0",
115
+ "vitest": "^5.0.1"
116
116
  }
117
117
  }