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