@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 +77 -15
- package/dist/cli/commands/guard.d.ts +3 -0
- package/dist/cli/commands/guard.js +24 -0
- package/dist/cli/index.js +2 -1
- package/dist/core/analysis/impact.d.ts +22 -0
- package/dist/core/analysis/impact.js +73 -0
- package/dist/core/discovery/discovery.d.ts +7 -0
- package/dist/core/discovery/discovery.js +16 -9
- package/dist/core/guard.d.ts +8 -0
- package/dist/core/guard.js +52 -2
- package/dist/core/history/history.d.ts +6 -0
- package/dist/core/history/history.js +26 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/tools/guard.js +3 -12
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/codapult/codapult-guard/actions/workflows/ci.yml)
|
|
4
4
|
[](https://github.com/codapult/codapult-guard/actions/workflows/guard-fixtures.yml)
|
|
5
|
+
[](https://www.npmjs.com/package/@codapult/guard)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
[](package.json)
|
|
7
8
|
[](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
|
|
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
|
-
| `
|
|
240
|
-
| `
|
|
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
|
-
|
|
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.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
.
|
|
348
|
-
|
|
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);
|
package/dist/core/guard.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/guard.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
package/dist/index.js
CHANGED
package/dist/mcp/tools/guard.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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@
|
|
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.
|
|
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.
|
|
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.
|
|
106
|
-
"@types/node": "^26.
|
|
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": "^
|
|
109
|
-
"eslint": "^
|
|
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.
|
|
113
|
-
"release-it": "^21.0.
|
|
114
|
-
"typescript-eslint": "^8.
|
|
115
|
-
"vitest": "^
|
|
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
|
}
|