@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.
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.
@@ -132,6 +168,10 @@ Guard does not assume a fixed `UI → actions → services → repositories →
132
168
  It can discover that shape when the project exhibits it, but observed patterns become enforceable
133
169
  only after explicit approval.
134
170
 
171
+ Policy paths are validated as project-relative paths. Guard rejects traversal, absolute paths, and
172
+ symlinks escaping the project root; import-boundary checks include imports, re-exports, and literal
173
+ dynamic imports.
174
+
135
175
  ## What Guard discovers
136
176
 
137
177
  The model is framework-aware without being framework-dependent:
@@ -150,6 +190,15 @@ The model is framework-aware without being framework-dependent:
150
190
  Capabilities are evidence, not requirements. A Vite app, Express service, Hono project, Node
151
191
  package, monorepo, or Next.js SaaS can all use the same Guard core.
152
192
 
193
+ ## What Guard is — and is not
194
+
195
+ Guard is an architecture control plane for AI-assisted development. It protects project-specific
196
+ boundaries and change impact using local facts, approved policy, and deterministic verification.
197
+
198
+ Guard is not a replacement for ESLint, TypeScript, tests, SAST, or a general-purpose PR bot. Those
199
+ tools answer different questions; Guard connects their results with the architectural memory that
200
+ an AI coding agent needs before and after changing a repository.
201
+
153
202
  ## The normal loop
154
203
 
155
204
  ```text
@@ -164,6 +213,12 @@ init once → edit → check --changed → review → verify → commit / merge
164
213
  contract validation.
165
214
  5. `audit` inspects the complete current state, including findings accepted by the baseline.
166
215
 
216
+ For a focused change explanation, use `codapult-guard impact <file...>` or the equivalent
217
+ `codapult_guard_impact` MCP tool. It reports both directions of the dependency graph: what the
218
+ changed module uses and which transitive callers may be affected. `review` includes the same
219
+ impact packet plus typed Git changes (`added`, `modified`, `deleted`, and `renamed`) for the AI
220
+ host.
221
+
167
222
  Guard state is stored in `.codapult/guard/`:
168
223
 
169
224
  ```text
@@ -194,10 +249,30 @@ Start the standalone MCP server over stdio:
194
249
  pnpm exec codapult-guard mcp-server
195
250
  ```
196
251
 
252
+ Register that command in the MCP client from the project root. For example, Cursor can use
253
+ `.cursor/mcp.json`:
254
+
255
+ ```json
256
+ {
257
+ "mcpServers": {
258
+ "codapult-guard": {
259
+ "command": "pnpm",
260
+ "args": ["exec", "codapult-guard", "mcp-server"],
261
+ "cwd": "."
262
+ }
263
+ }
264
+ }
265
+ ```
266
+
267
+ For clients that accept a generic stdio server, use the same command and set its working
268
+ directory to the project root. Guard reads local files only; no API key or model provider is
269
+ required. Host-specific examples are in [`docs/integrations/`](docs/integrations/).
270
+
197
271
  Recommended agent loop:
198
272
 
199
273
  ```text
200
274
  task finished
275
+ → codapult_guard_next_action
201
276
  → codapult_guard_context
202
277
  → codapult_guard_review(requirement, diff)
203
278
  → codapult_guard_verify
@@ -225,19 +300,24 @@ real project shapes.
225
300
 
226
301
  ## CLI surface
227
302
 
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. |
303
+ | Command | Purpose |
304
+ | -------------------------- | ------------------------------------------------------------------------------------------- |
305
+ | `init` | Create project memory and the initial baseline. |
306
+ | `analyze` | Refresh facts without changing policy or baseline. |
307
+ | `propose` | Generate evidence-based rule and contract proposals. |
308
+ | `check --changed` | Enforce active policy on changed and untracked files. |
309
+ | `audit` | Scan the complete current project, including baseline findings. |
310
+ | `review` | Create a bounded semantic-review packet for an AI host. |
311
+ | `verify` | Run the configured completion gate. |
312
+ | `doctor` | Diagnose invalid or missing Guard artifacts. |
313
+ | `history` / `history-diff` | Inspect project model, module graph, and architecture-edge evolution. |
314
+ | `impact <files...>` | Explain dependencies, transitive dependents, capabilities, and contracts affected by files. |
315
+ | `policy explain <id>` | Explain a policy item, its evidence, and approval history. |
316
+ | `rules` / `contracts` | Approve or reject proposed policy. |
317
+ | `baseline` | Review or intentionally accept existing findings. |
318
+
319
+ Use `analyze --refresh` after a structural change. `doctor --fix-cache` removes only the disposable
320
+ discovery cache; it does not change rules, contracts, baseline, or source files.
241
321
 
242
322
  Run `pnpm exec codapult-guard <command> --help` for command-specific options.
243
323
 
@@ -1,4 +1,4 @@
1
- import { execSync } from 'node:child_process';
1
+ import { execFileSync } from 'node:child_process';
2
2
  const MAX_OUTPUT_CHARS = 20_000;
3
3
  const MAX_BUFFER_BYTES = 2_000_000;
4
4
  function redactOutput(value) {
@@ -13,9 +13,30 @@ function captureOutput(value) {
13
13
  ? { value: `${redacted.slice(0, MAX_OUTPUT_CHARS)}\n[output truncated]`, truncated: true }
14
14
  : { value: redacted, truncated: false };
15
15
  }
16
+ function parseCommand(command) {
17
+ const parts = command.trim().split(/\s+/).filter(Boolean);
18
+ if (parts.length === 0 || parts.some((part) => /[;&|<>`$()]/.test(part)))
19
+ return undefined;
20
+ const [executable, ...args] = parts;
21
+ const windowsExecutable = process.platform === 'win32' && /^(?:npm|npx|pnpm|yarn|bun)$/.test(executable)
22
+ ? `${executable}.cmd`
23
+ : executable;
24
+ return { executable: windowsExecutable, args };
25
+ }
16
26
  export function runProjectCommand(command, cwd, options = {}) {
27
+ const parsed = parseCommand(command);
28
+ if (!parsed) {
29
+ return {
30
+ command,
31
+ status: 'failed',
32
+ passed: false,
33
+ exitCode: 2,
34
+ stdout: '',
35
+ stderr: 'Unsafe or empty project command rejected.',
36
+ };
37
+ }
17
38
  try {
18
- const stdout = execSync(command, {
39
+ const stdout = execFileSync(parsed.executable, parsed.args, {
19
40
  cwd,
20
41
  env: { ...process.env, ...options.env },
21
42
  stdio: 'pipe',
@@ -2,7 +2,9 @@ import { type GuardToolMode } from '../../core/guard.js';
2
2
  export declare function guardInitCommand(options?: {
3
3
  force?: boolean;
4
4
  }): void;
5
- export declare function guardAnalyzeCommand(): void;
5
+ export declare function guardAnalyzeCommand(_options?: {
6
+ refresh?: boolean;
7
+ }): void;
6
8
  export declare function guardProposeCommand(options?: {
7
9
  json?: boolean;
8
10
  }): void;
@@ -11,8 +13,12 @@ export declare function guardInstallAgentCommand(target?: string, options?: {
11
13
  }): void;
12
14
  export declare function guardDoctorCommand(options?: {
13
15
  json?: boolean;
16
+ fixCache?: boolean;
14
17
  }): void;
15
18
  export declare function guardHistoryCommand(): void;
19
+ export declare function guardImpactCommand(files: string[], options?: {
20
+ json?: boolean;
21
+ }): void;
16
22
  export declare function guardHistoryDiffCommand(from: string, to: string, options?: {
17
23
  json?: boolean;
18
24
  }): void;
@@ -41,6 +47,9 @@ export declare function guardBaselineCommand(action: 'list' | 'accept' | 'remove
41
47
  export declare function guardRulesApproveCommand(ids?: string, options?: {
42
48
  all?: boolean;
43
49
  }): void;
50
+ export declare function guardPolicyExplainCommand(id: string, options?: {
51
+ json?: boolean;
52
+ }): void;
44
53
  export declare function guardContractsApproveCommand(ids?: string, options?: {
45
54
  all?: boolean;
46
55
  }): void;
@@ -1,22 +1,46 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { relative, resolve } from 'node:path';
3
- import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, findGuardRoot, discoverProjectWithMetrics, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, updateBaseline, loadGuardConfig, loadGuardProposals, writeGuardConfig, writeGuardProposals, recordGuardProposalDecision, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardContracts, writeProjectModel, writeGuardMemory, writeProjectSnapshot, } from '../../core/guard.js';
3
+ import { GUARD_BASELINE_FILE, GUARD_BASELINE_META_FILE, GUARD_ARCHITECTURE_FILE, GUARD_CONVENTIONS_FILE, GUARD_AGENT_FILE, GUARD_CONTRACTS_FILE, GUARD_PROPOSALS_FILE, GUARD_PROJECT_FILE, GUARD_HISTORY_DIR, GUARD_RULES_FILE, findGuardRoot, discoverProjectWithMetrics, clearDiscoveryCache, buildGuardReviewPacket, buildGuardProposals, buildGeneratedGuardConfig, discoverProject, GuardAlreadyInitializedError, initializeGuard, loadBaseline, updateBaseline, loadGuardConfig, loadGuardProposals, writeGuardConfig, writeGuardProposals, recordGuardProposalDecision, getGuardProposalFreshness, scanGuard, classifyGuardOutcome, validateGuardContracts, writeProjectModel, writeGuardMemory, writeProjectSnapshot, } from '../../core/guard.js';
4
+ import { guardErrorPayload } from '../../core/errors.js';
4
5
  import { runGuardVerification, } from '../../core/verification/verify.js';
5
6
  import { diagnoseGuard } from '../../core/analysis/doctor.js';
6
7
  import { diffGuardSnapshots, listGuardSnapshots } from '../../core/history/history.js';
7
8
  import { guardAgentTargets, installGuardAgentInstructions, installGuardAgentTargets, } from '../../adapters/agents/agent-integration.js';
8
9
  import { dim, fail, heading, info, success, warn } from '../ui.js';
9
10
  import { guardFindingsToSarif } from '../../core/output/sarif.js';
11
+ import { analyzeProjectImpact } from '../../core/analysis/impact.js';
10
12
  function renderFindings(findings) {
11
13
  for (const finding of findings) {
12
14
  const printer = finding.severity === 'error' ? fail : finding.severity === 'warning' ? warn : info;
13
15
  printer(`${finding.file}:${finding.line} [${finding.ruleId}] ${finding.message}`);
14
16
  dim(` import: ${finding.importPath}`);
17
+ if (finding.resolvedPath)
18
+ dim(` resolved: ${finding.resolvedPath}`);
15
19
  }
16
20
  }
17
21
  function getRoot() {
18
22
  return findGuardRoot();
19
23
  }
24
+ function loadConfigSafely(root, options = {}) {
25
+ try {
26
+ return { config: loadGuardConfig(root), invalid: false };
27
+ }
28
+ catch (error) {
29
+ const message = error instanceof Error ? error.message : 'Guard configuration is invalid.';
30
+ const payload = guardErrorPayload('GUARD_CONFIG_INVALID', message, {
31
+ configured: false,
32
+ outcome: 'error',
33
+ recoverable: true,
34
+ hint: 'Repair or remove the invalid Guard artifact, then run `codapult-guard doctor`.',
35
+ });
36
+ if (options.json || options.machine)
37
+ console.log(JSON.stringify(payload, null, 2));
38
+ else
39
+ fail(`${payload.message} Run \`codapult-guard doctor\` for details.`);
40
+ process.exitCode = 1;
41
+ return { invalid: true };
42
+ }
43
+ }
20
44
  function readRequirement(root, file) {
21
45
  if (!file)
22
46
  return undefined;
@@ -73,7 +97,7 @@ export function guardInitCommand(options = {}) {
73
97
  info(`${config.rules.length} rule(s) generated (${proposedRules} proposed); ${report.scannedFiles} source file(s) scanned.`);
74
98
  dim('Existing findings are baselined. New violations will be reported by `codapult-guard check`.');
75
99
  }
76
- export function guardAnalyzeCommand() {
100
+ export function guardAnalyzeCommand(_options = {}) {
77
101
  const root = getRoot();
78
102
  const { model: projectModel, metrics } = discoverProjectWithMetrics(root, {
79
103
  persistCache: true,
@@ -90,7 +114,10 @@ export function guardAnalyzeCommand() {
90
114
  export function guardProposeCommand(options = {}) {
91
115
  const root = getRoot();
92
116
  const model = discoverProject(root);
93
- const config = loadGuardConfig(root) ?? buildGeneratedGuardConfig(model);
117
+ const loaded = loadConfigSafely(root, options);
118
+ if (loaded.invalid)
119
+ return;
120
+ const config = loaded.config ?? buildGeneratedGuardConfig(model);
94
121
  const proposals = buildGuardProposals(model, config);
95
122
  const previous = loadGuardProposals(root);
96
123
  writeGuardProposals(root, {
@@ -134,9 +161,12 @@ export function guardInstallAgentCommand(target = 'generic', options = {}) {
134
161
  process.exitCode = 0;
135
162
  }
136
163
  export function guardDoctorCommand(options = {}) {
164
+ if (options.fixCache)
165
+ clearDiscoveryCache(getRoot());
137
166
  const report = diagnoseGuard(getRoot());
167
+ const result = options.fixCache ? { ...report, cacheFixed: true } : report;
138
168
  if (options.json) {
139
- console.log(JSON.stringify(report, null, 2));
169
+ console.log(JSON.stringify(result, null, 2));
140
170
  }
141
171
  else {
142
172
  heading('Codapult Guard Doctor');
@@ -153,7 +183,7 @@ export function guardDoctorCommand(options = {}) {
153
183
  else
154
184
  success('Guard state is healthy.');
155
185
  }
156
- process.exitCode = report.status === 'fail' ? 1 : 0;
186
+ process.exitCode = result.status === 'fail' ? 1 : 0;
157
187
  }
158
188
  export function guardHistoryCommand() {
159
189
  const snapshots = listGuardSnapshots(getRoot());
@@ -166,6 +196,29 @@ export function guardHistoryCommand() {
166
196
  info(`${snapshot.revision}: ${snapshot.files} files, ${snapshot.modules} modules, ${snapshot.cycles} cycles`);
167
197
  }
168
198
  }
199
+ export function guardImpactCommand(files, options = {}) {
200
+ const root = getRoot();
201
+ if (files.length === 0) {
202
+ fail('Specify at least one project-relative file.');
203
+ process.exitCode = 1;
204
+ return;
205
+ }
206
+ const loaded = loadConfigSafely(root, options);
207
+ if (loaded.invalid)
208
+ return;
209
+ const result = analyzeProjectImpact(discoverProject(root), files, loaded.config?.contracts ?? []);
210
+ if (options.json) {
211
+ console.log(JSON.stringify({ status: 'ok', ...result }, null, 2));
212
+ return;
213
+ }
214
+ heading('Codapult Guard Impact');
215
+ info(`Requested: ${result.requestedFiles.join(', ')}`);
216
+ info(`Direct modules: ${result.directModules.join(', ') || 'none'}`);
217
+ info(`Dependencies: ${result.dependencies.join(', ') || 'none'}`);
218
+ info(`Transitive dependents: ${result.dependents.join(', ') || 'none'}`);
219
+ info(`Capabilities: ${result.capabilities.join(', ') || 'none'}`);
220
+ info(`Relevant contracts: ${result.relevantContracts.map((contract) => contract.id).join(', ') || 'none'}`);
221
+ }
169
222
  export function guardHistoryDiffCommand(from, to, options = {}) {
170
223
  const diff = diffGuardSnapshots(getRoot(), from, to);
171
224
  if (!diff) {
@@ -181,6 +234,9 @@ export function guardHistoryDiffCommand(from, to, options = {}) {
181
234
  info(`Removed files: ${diff.removedFiles.length}`);
182
235
  info(`Added dependencies: ${diff.addedDependencies.join(', ') || 'none'}`);
183
236
  info(`Removed dependencies: ${diff.removedDependencies.join(', ') || 'none'}`);
237
+ info(`Modules: +${diff.addedModules.length} / -${diff.removedModules.length}`);
238
+ info(`Dependency edges: +${diff.addedDependencyEdges.length} / -${diff.removedDependencyEdges.length}`);
239
+ info(`Layer edges: +${diff.addedLayerEdges.length} / -${diff.removedLayerEdges.length}`);
184
240
  info(`Capabilities: +${diff.addedCapabilities.join(', ') || 'none'} / -${diff.removedCapabilities.join(', ') || 'none'}`);
185
241
  info(`Cycles: ${diff.cycles.from} → ${diff.cycles.to}`);
186
242
  }
@@ -244,10 +300,20 @@ export function guardVerifyCommand(options = {}) {
244
300
  }
245
301
  export function guardCheckCommand(options = {}) {
246
302
  const root = getRoot();
247
- const config = loadGuardConfig(root);
303
+ const loaded = loadConfigSafely(root, { ...options, machine: options.json || options.sarif });
304
+ if (loaded.invalid)
305
+ return;
306
+ const config = loaded.config;
248
307
  if (!config) {
249
308
  if (options.json) {
250
- console.log(JSON.stringify({ configured: false, error: 'Guard is not initialized' }));
309
+ console.log(JSON.stringify({
310
+ status: 'error',
311
+ outcome: 'not-configured',
312
+ configured: false,
313
+ errorCode: 'GUARD_NOT_CONFIGURED',
314
+ message: 'Guard is not initialized.',
315
+ recoverable: true,
316
+ }));
251
317
  process.exitCode = 1;
252
318
  return;
253
319
  }
@@ -304,10 +370,20 @@ export function guardCheckCommand(options = {}) {
304
370
  }
305
371
  export function guardAuditCommand(options = {}) {
306
372
  const root = getRoot();
307
- const config = loadGuardConfig(root);
373
+ const loaded = loadConfigSafely(root, options);
374
+ if (loaded.invalid)
375
+ return;
376
+ const config = loaded.config;
308
377
  if (!config) {
309
378
  if (options.json) {
310
- console.log(JSON.stringify({ configured: false, error: 'Guard is not initialized' }));
379
+ console.log(JSON.stringify({
380
+ status: 'error',
381
+ outcome: 'not-configured',
382
+ configured: false,
383
+ errorCode: 'GUARD_NOT_CONFIGURED',
384
+ message: 'Guard is not initialized.',
385
+ recoverable: true,
386
+ }));
311
387
  process.exitCode = 1;
312
388
  return;
313
389
  }
@@ -364,9 +440,12 @@ export function guardBaselineCommand(action, ids, options = {}) {
364
440
  process.exitCode = 0;
365
441
  return;
366
442
  }
443
+ const loaded = options.all ? loadConfigSafely(root, options) : { invalid: false };
444
+ if (loaded.invalid)
445
+ return;
367
446
  const selected = options.all
368
447
  ? [
369
- ...scanGuard(root, loadGuardConfig(root) ?? buildGeneratedGuardConfig(discoverProject(root)), {
448
+ ...scanGuard(root, loaded.config ?? buildGeneratedGuardConfig(discoverProject(root)), {
370
449
  includeArchitectureInsights: true,
371
450
  }).findings.map((finding) => finding.fingerprint),
372
451
  ]
@@ -391,7 +470,10 @@ export function guardBaselineCommand(action, ids, options = {}) {
391
470
  }
392
471
  export function guardRulesApproveCommand(ids, options = {}) {
393
472
  const root = getRoot();
394
- const config = loadGuardConfig(root);
473
+ const loaded = loadConfigSafely(root);
474
+ if (loaded.invalid)
475
+ return;
476
+ const config = loaded.config;
395
477
  if (!config) {
396
478
  fail('Guard is not initialized. Run `codapult-guard init` first.');
397
479
  process.exitCode = 1;
@@ -418,9 +500,55 @@ export function guardRulesApproveCommand(ids, options = {}) {
418
500
  success(`Activated ${rule.id}`);
419
501
  process.exitCode = 0;
420
502
  }
503
+ export function guardPolicyExplainCommand(id, options = {}) {
504
+ const root = getRoot();
505
+ const loaded = loadConfigSafely(root, options);
506
+ if (loaded.invalid)
507
+ return;
508
+ const config = loaded.config;
509
+ const proposals = loadGuardProposals(root);
510
+ if (!config) {
511
+ fail('Guard is not initialized. Run `codapult-guard init` first.');
512
+ process.exitCode = 1;
513
+ return;
514
+ }
515
+ const item = [...config.rules, ...(config.contracts ?? [])].find((entry) => entry.id === id);
516
+ const proposed = [...(proposals?.rules ?? []), ...(proposals?.contracts ?? [])].find((entry) => entry.id === id);
517
+ if (!item && !proposed) {
518
+ fail(`Policy item not found: ${id}`);
519
+ process.exitCode = 1;
520
+ return;
521
+ }
522
+ const result = {
523
+ id,
524
+ active: item?.status !== 'proposed' && item !== undefined,
525
+ definition: item ?? proposed,
526
+ proposal: proposed,
527
+ decisions: proposals?.decisions?.filter((decision) => decision.id === id) ?? [],
528
+ };
529
+ if (options.json)
530
+ console.log(JSON.stringify(result, null, 2));
531
+ else {
532
+ heading(`Guard Policy: ${id}`);
533
+ info(`Status: ${result.active ? 'active' : 'proposed'}`);
534
+ const definition = result.definition;
535
+ if (definition && 'description' in definition)
536
+ dim(definition.description);
537
+ if (definition && 'statement' in definition)
538
+ dim(definition.statement);
539
+ if ((definition?.evidence?.length ?? 0) > 0)
540
+ dim(`Evidence: ${definition?.evidence?.join(', ')}`);
541
+ if (result.decisions.length > 0)
542
+ dim(`Decisions: ${result.decisions.length}`);
543
+ }
544
+ process.exitCode = 0;
545
+ }
421
546
  export function guardContractsApproveCommand(ids, options = {}) {
422
547
  const root = getRoot();
423
- const config = loadGuardConfig(root);
548
+ const loaded = loadConfigSafely(root);
549
+ if (loaded.invalid)
550
+ return;
551
+ const config = loaded.config;
424
552
  if (!config) {
425
553
  fail('Guard is not initialized. Run `codapult-guard init` first.');
426
554
  process.exitCode = 1;
@@ -453,7 +581,10 @@ export function guardContractsApproveCommand(ids, options = {}) {
453
581
  }
454
582
  export function guardContractsRejectCommand(ids, options = {}) {
455
583
  const root = getRoot();
456
- const config = loadGuardConfig(root);
584
+ const loaded = loadConfigSafely(root);
585
+ if (loaded.invalid)
586
+ return;
587
+ const config = loaded.config;
457
588
  if (!config) {
458
589
  fail('Guard is not initialized. Run `codapult-guard init` first.');
459
590
  process.exitCode = 1;
@@ -488,7 +619,10 @@ export function guardReviewCommand(options = {}) {
488
619
  const requirement = readRequirement(root, options.requirement);
489
620
  if (options.requirement && !requirement)
490
621
  return;
491
- const config = loadGuardConfig(root);
622
+ const loaded = loadConfigSafely(root);
623
+ if (loaded.invalid)
624
+ return;
625
+ const config = loaded.config;
492
626
  if (!config) {
493
627
  fail(`Guard is not initialized. Run \`codapult-guard init\` first.`);
494
628
  process.exitCode = 1;
package/dist/cli/index.js CHANGED
@@ -1,8 +1,10 @@
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, guardPolicyExplainCommand, guardProposeCommand, guardReviewCommand, guardRulesApproveCommand, guardVerifyCommand, } from './commands/guard.js';
5
5
  import { config } from '../core/config.js';
6
+ import { GuardConfigError, GuardStateBusyError } from '../core/guard.js';
7
+ import { guardErrorPayload } from '../core/errors.js';
6
8
  const program = new Command()
7
9
  .name(config.commandName)
8
10
  .description('Local-first architecture guardrails for JavaScript and TypeScript projects')
@@ -15,12 +17,13 @@ const program = new Command()
15
17
  // `codapult-guard init`, not `codapult-guard guard init`.
16
18
  const guard = program;
17
19
  guard.command('init').option('--force').action(guardInitCommand);
18
- guard.command('analyze').action(guardAnalyzeCommand);
20
+ guard.command('analyze').option('--refresh').action(guardAnalyzeCommand);
19
21
  guard.command('propose').option('--json').action(guardProposeCommand);
20
22
  guard.command('install-agent [target]').option('--json').action(guardInstallAgentCommand);
21
- guard.command('doctor').option('--json').action(guardDoctorCommand);
23
+ guard.command('doctor').option('--json').option('--fix-cache').action(guardDoctorCommand);
22
24
  guard.command('history').action(guardHistoryCommand);
23
25
  guard.command('history-diff <from> <to>').option('--json').action(guardHistoryDiffCommand);
26
+ guard.command('impact <files...>').option('--json').action(guardImpactCommand);
24
27
  guard
25
28
  .command('check')
26
29
  .option('--changed')
@@ -46,6 +49,8 @@ guard
46
49
  .action(guardReviewCommand);
47
50
  const rules = guard.command('rules');
48
51
  rules.command('approve [ids]').option('--all').action(guardRulesApproveCommand);
52
+ const policy = guard.command('policy');
53
+ policy.command('explain <id>').option('--json').action(guardPolicyExplainCommand);
49
54
  const contracts = guard.command('contracts');
50
55
  contracts.command('approve [ids]').option('--all').action(guardContractsApproveCommand);
51
56
  contracts.command('reject [ids]').option('--all').action(guardContractsRejectCommand);
@@ -72,4 +77,23 @@ program
72
77
  .action(async () => {
73
78
  await import('../mcp/server.js');
74
79
  });
75
- program.parse();
80
+ try {
81
+ program.parse();
82
+ }
83
+ catch (error) {
84
+ if (error instanceof GuardConfigError || error instanceof GuardStateBusyError) {
85
+ const isBusy = error instanceof GuardStateBusyError;
86
+ console.error(JSON.stringify(guardErrorPayload(isBusy ? 'GUARD_STATE_BUSY' : 'GUARD_CONFIG_INVALID', error.message, {
87
+ configured: !isBusy,
88
+ outcome: 'error',
89
+ recoverable: true,
90
+ hint: isBusy
91
+ ? 'Retry after the other Guard process finishes.'
92
+ : 'Repair the invalid Guard artifact, then run codapult-guard doctor.',
93
+ }), null, 2));
94
+ process.exitCode = 1;
95
+ }
96
+ else {
97
+ throw error;
98
+ }
99
+ }
@@ -22,12 +22,18 @@ function parseJson(root, path) {
22
22
  export function diagnoseGuard(root) {
23
23
  const initialized = existsSync(resolve(root, GUARD_BASELINE_FILE));
24
24
  const items = requiredArtifacts.map((path) => {
25
- const guardConfig = path === GUARD_CONTRACTS_FILE ? loadGuardConfig(root) : undefined;
26
25
  if (!existsSync(resolve(root, path))) {
27
26
  return { path, status: 'missing', message: 'Artifact is missing.' };
28
27
  }
29
- if (path === GUARD_RULES_FILE && !loadGuardConfig(root)) {
30
- return { path, status: 'invalid', message: 'Guard rules are invalid or unsupported.' };
28
+ if (path === GUARD_RULES_FILE) {
29
+ try {
30
+ if (!loadGuardConfig(root)) {
31
+ return { path, status: 'invalid', message: 'Guard rules are invalid or unsupported.' };
32
+ }
33
+ }
34
+ catch {
35
+ return { path, status: 'invalid', message: 'Guard rules are invalid or unsupported.' };
36
+ }
31
37
  }
32
38
  if (path === GUARD_PROJECT_FILE && !loadProjectModel(root)) {
33
39
  return { path, status: 'invalid', message: 'Project model is invalid or unsupported.' };
@@ -39,8 +45,16 @@ export function diagnoseGuard(root) {
39
45
  return { path, status: 'invalid', message: 'Guard contracts are invalid or unsupported.' };
40
46
  }
41
47
  if (path === GUARD_CONTRACTS_FILE &&
42
- guardConfig &&
43
- validateGuardContracts(root, guardConfig.contracts).length > 0) {
48
+ (() => {
49
+ try {
50
+ const guardConfig = loadGuardConfig(root);
51
+ return (guardConfig !== undefined &&
52
+ validateGuardContracts(root, guardConfig.contracts).length > 0);
53
+ }
54
+ catch {
55
+ return true;
56
+ }
57
+ })()) {
44
58
  return { path, status: 'invalid', message: 'Guard contract scopes or references are stale.' };
45
59
  }
46
60
  if (path === GUARD_PROPOSALS_FILE && !isGuardProposalFile(parseJson(root, path))) {
@@ -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;