@codapult/guard 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,11 @@
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';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { hostname } from 'node:os';
4
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
4
5
  import { dirname, extname, relative, resolve, sep } from 'node:path';
5
6
  import { Project, SyntaxKind } from 'ts-morph';
6
- import { discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
7
+ import { buildModuleTargetGraph } from './discovery/discovery.js';
8
+ import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
7
9
  import { config } from './config.js';
8
10
  import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
9
11
  import { analyzeProjectImpact } from './analysis/impact.js';
@@ -28,10 +30,20 @@ export const GUARD_CONVENTIONS_FILE = `${GUARD_DIR}/conventions.json`;
28
30
  export const GUARD_AGENT_FILE = `${GUARD_DIR}/agent.json`;
29
31
  export const GUARD_CONTRACTS_FILE = `${GUARD_DIR}/contracts.json`;
30
32
  export const GUARD_PROPOSALS_FILE = `${GUARD_DIR}/proposals.json`;
33
+ export const GUARD_STATE_DIR = `${GUARD_DIR}/state`;
34
+ export const GUARD_STATE_GENERATIONS_DIR = `${GUARD_STATE_DIR}/generations`;
35
+ export const GUARD_STATE_CURRENT_FILE = `${GUARD_STATE_DIR}/current.json`;
36
+ const GUARD_POLICY_TRANSACTION_FILE = `${GUARD_STATE_DIR}/policy-transaction.json`;
37
+ const GUARD_STATE_LOCK_FILE = `${GUARD_DIR}/.state.lock`;
31
38
  export const defaultGuardConfig = {
32
39
  version: 1,
33
40
  rules: [],
34
41
  contracts: [],
42
+ approval: {
43
+ mode: 'local',
44
+ allowMcpApproval: true,
45
+ requireDistinctActor: false,
46
+ },
35
47
  };
36
48
  export const defaultGuardAgentConfig = {
37
49
  version: 1,
@@ -50,6 +62,154 @@ export class GuardAlreadyInitializedError extends Error {
50
62
  this.name = 'GuardAlreadyInitializedError';
51
63
  }
52
64
  }
65
+ export class GuardConfigError extends Error {
66
+ constructor(path) {
67
+ super(`Guard configuration is invalid or unsupported: ${path}`);
68
+ this.name = 'GuardConfigError';
69
+ }
70
+ }
71
+ export class GuardStateBusyError extends Error {
72
+ constructor(path) {
73
+ super(`Guard state is being updated by another process: ${path}`);
74
+ this.name = 'GuardStateBusyError';
75
+ }
76
+ }
77
+ export class GuardStateStaleError extends Error {
78
+ constructor() {
79
+ super('Guard state changed while this operation was running.');
80
+ this.name = 'GuardStateStaleError';
81
+ }
82
+ }
83
+ export class GuardBaselineReasonError extends Error {
84
+ constructor() {
85
+ super('A reason is required when changing the Guard baseline.');
86
+ this.name = 'GuardBaselineReasonError';
87
+ }
88
+ }
89
+ const activeStateLocks = new Map();
90
+ function processIsAlive(pid) {
91
+ try {
92
+ process.kill(pid, 0);
93
+ return true;
94
+ }
95
+ catch (error) {
96
+ return error.code === 'EPERM';
97
+ }
98
+ }
99
+ function sleepSync(milliseconds) {
100
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
101
+ }
102
+ function removeLockFile(path) {
103
+ try {
104
+ unlinkSync(path);
105
+ }
106
+ catch {
107
+ // Lock cleanup is best effort. A later invocation can recover an orphaned lock by PID.
108
+ }
109
+ }
110
+ function readStateLock(path) {
111
+ try {
112
+ const value = JSON.parse(readFileSync(path, 'utf8'));
113
+ const hasValidDate = (candidate) => typeof candidate === 'string' && Number.isFinite(Date.parse(candidate));
114
+ return Number.isInteger(value.pid) &&
115
+ value.pid > 0 &&
116
+ typeof value.hostname === 'string' &&
117
+ value.hostname.length > 0 &&
118
+ typeof value.command === 'string' &&
119
+ typeof value.token === 'string' &&
120
+ value.token.length > 0 &&
121
+ hasValidDate(value.createdAt) &&
122
+ hasValidDate(value.expiresAt)
123
+ ? value
124
+ : undefined;
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ function getActiveStateRoot(path) {
131
+ const normalizedPath = resolve(path);
132
+ return [...activeStateLocks.keys()]
133
+ .filter((root) => normalizedPath === root || normalizedPath.startsWith(`${root}${sep}`))
134
+ .sort((left, right) => right.length - left.length)[0];
135
+ }
136
+ function safeGeneration(value) {
137
+ return typeof value === 'string' && /^[A-Za-z0-9._-]+$/.test(value);
138
+ }
139
+ export function withGuardStateLock(root, callback, options = {}) {
140
+ const normalizedRoot = resolve(root);
141
+ const activeDepth = activeStateLocks.get(normalizedRoot);
142
+ if (activeDepth !== undefined) {
143
+ activeStateLocks.set(normalizedRoot, activeDepth + 1);
144
+ try {
145
+ return callback();
146
+ }
147
+ finally {
148
+ activeStateLocks.set(normalizedRoot, activeDepth);
149
+ }
150
+ }
151
+ const lockPath = resolve(normalizedRoot, GUARD_STATE_LOCK_FILE);
152
+ mkdirSync(dirname(lockPath), { recursive: true });
153
+ const startedAt = Date.now();
154
+ const waitMs = options.noWait ? 0 : (options.waitMs ?? 30_000);
155
+ let lock;
156
+ let delay = 25;
157
+ while (!lock) {
158
+ const now = new Date();
159
+ const candidate = {
160
+ pid: process.pid,
161
+ hostname: hostname(),
162
+ command: process.argv.join(' '),
163
+ token: randomUUID(),
164
+ createdAt: now.toISOString(),
165
+ expiresAt: new Date(now.getTime() + 300_000).toISOString(),
166
+ };
167
+ try {
168
+ writeFileSync(lockPath, `${JSON.stringify(candidate)}\n`, { encoding: 'utf8', flag: 'wx' });
169
+ lock = candidate;
170
+ break;
171
+ }
172
+ catch (error) {
173
+ if (error.code !== 'EEXIST')
174
+ throw error;
175
+ const owner = readStateLock(lockPath);
176
+ const ownerDead = owner?.hostname === hostname() && !processIsAlive(owner.pid);
177
+ let malformedStale = false;
178
+ if (owner === undefined) {
179
+ try {
180
+ malformedStale = Date.now() - statSync(lockPath).mtimeMs > 60_000;
181
+ }
182
+ catch {
183
+ // The lock disappeared between the failed create and stat.
184
+ }
185
+ }
186
+ if (ownerDead || malformedStale) {
187
+ try {
188
+ unlinkSync(lockPath);
189
+ continue;
190
+ }
191
+ catch {
192
+ // The owner or another waiter changed the lock; retry normally.
193
+ }
194
+ }
195
+ if (Date.now() - startedAt >= waitMs)
196
+ throw new GuardStateBusyError(lockPath);
197
+ sleepSync(Math.min(delay, Math.max(1, waitMs - (Date.now() - startedAt))));
198
+ delay = Math.min(delay * 2, 500);
199
+ }
200
+ }
201
+ activeStateLocks.set(normalizedRoot, 1);
202
+ try {
203
+ return callback();
204
+ }
205
+ finally {
206
+ activeStateLocks.delete(normalizedRoot);
207
+ const current = readStateLock(lockPath);
208
+ if (current?.token === lock.token) {
209
+ removeLockFile(lockPath);
210
+ }
211
+ }
212
+ }
53
213
  function isGuardConfig(value) {
54
214
  return guardConfigSchema.safeParse(value).success;
55
215
  }
@@ -62,14 +222,52 @@ function readJson(filePath) {
62
222
  }
63
223
  }
64
224
  function atomicWriteFile(path, content) {
65
- const temporaryPath = `${path}.tmp-${process.pid}`;
66
- writeFileSync(temporaryPath, content, 'utf8');
67
- renameSync(temporaryPath, path);
225
+ if (getActiveStateRoot(path) !== undefined) {
226
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
227
+ writeFileSync(temporaryPath, content, 'utf8');
228
+ renameSync(temporaryPath, path);
229
+ return;
230
+ }
231
+ const lockPath = `${path}.lock`;
232
+ let lockAcquired = false;
233
+ try {
234
+ try {
235
+ writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
236
+ lockAcquired = true;
237
+ }
238
+ catch (error) {
239
+ if (error.code === 'EEXIST') {
240
+ try {
241
+ if (Date.now() - statSync(lockPath).mtimeMs > 60_000) {
242
+ unlinkSync(lockPath);
243
+ writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
244
+ lockAcquired = true;
245
+ }
246
+ }
247
+ catch {
248
+ // Another writer may have replaced or removed the lock.
249
+ }
250
+ }
251
+ if (!lockAcquired)
252
+ throw new GuardStateBusyError(path);
253
+ }
254
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
255
+ writeFileSync(temporaryPath, content, 'utf8');
256
+ renameSync(temporaryPath, path);
257
+ }
258
+ finally {
259
+ if (lockAcquired) {
260
+ removeLockFile(lockPath);
261
+ }
262
+ }
68
263
  }
69
- function writeGuardArtifact(root, relativePath, value) {
264
+ function writeGuardArtifactUnlocked(root, relativePath, value) {
70
265
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
71
266
  atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
72
267
  }
268
+ function writeGuardArtifact(root, relativePath, value) {
269
+ withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
270
+ }
73
271
  function hasProjectTool(model, name) {
74
272
  return (Object.keys({ ...model.project.dependencies, ...model.project.devDependencies }).some((dependency) => dependency === name || dependency.endsWith(`/${name}`)) || Object.values(model.project.scripts).some((script) => script.includes(name)));
75
273
  }
@@ -102,106 +300,270 @@ export function isGuardProposalFile(value) {
102
300
  export function fingerprintProjectModel(model) {
103
301
  return createHash('sha256').update(JSON.stringify(model)).digest('hex');
104
302
  }
303
+ export function fingerprintGuardConfig(guardConfig) {
304
+ return createHash('sha256')
305
+ .update(JSON.stringify({
306
+ rules: guardConfig.rules,
307
+ contracts: guardConfig.contracts ?? [],
308
+ budgets: guardConfig.budgets ?? [],
309
+ approval: guardConfig.approval ?? defaultGuardConfig.approval,
310
+ }))
311
+ .digest('hex');
312
+ }
105
313
  export function getGuardProposalFreshness(model, proposals) {
106
314
  if (!proposals?.projectFingerprint)
107
315
  return 'unknown';
108
316
  return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
109
317
  }
110
- export function loadGuardConfig(root) {
111
- const value = readJson(resolve(root, GUARD_RULES_FILE));
112
- if (!isGuardConfig(value))
318
+ function readGuardConfigFilesUnlocked(root) {
319
+ const rulesPath = resolve(root, GUARD_RULES_FILE);
320
+ if (!existsSync(rulesPath))
113
321
  return undefined;
322
+ const value = readJson(rulesPath);
323
+ if (!isGuardConfig(value))
324
+ throw new GuardConfigError(GUARD_RULES_FILE);
114
325
  const contractsPath = resolve(root, GUARD_CONTRACTS_FILE);
115
326
  if (existsSync(contractsPath)) {
116
327
  const contractFile = readJson(contractsPath);
117
328
  if (!isGuardContractsFile(contractFile))
118
- return undefined;
119
- return { ...value, contracts: contractFile.contracts };
329
+ throw new GuardConfigError(GUARD_CONTRACTS_FILE);
330
+ return {
331
+ ...value,
332
+ contracts: contractFile.contracts,
333
+ budgets: value.budgets ?? [],
334
+ approval: value.approval ?? defaultGuardConfig.approval,
335
+ };
120
336
  }
121
337
  return {
122
338
  ...value,
123
339
  contracts: value.contracts ?? [],
340
+ budgets: value.budgets ?? [],
341
+ approval: value.approval ?? defaultGuardConfig.approval,
124
342
  };
125
343
  }
344
+ function isGuardPolicyTransaction(value) {
345
+ if (value === null || typeof value !== 'object')
346
+ return false;
347
+ const transaction = value;
348
+ return (transaction.version === 1 &&
349
+ typeof transaction.createdAt === 'string' &&
350
+ isGuardConfig(transaction.config) &&
351
+ (transaction.proposals === undefined || isGuardProposalFile(transaction.proposals)));
352
+ }
353
+ function writeGuardConfigUnlocked(root, guardConfig, options = {}) {
354
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
355
+ const current = readGuardConfigFilesUnlocked(root);
356
+ const currentRevision = current?.revision ?? 0;
357
+ const expectedRevision = options.expectedRevision ?? guardConfig.revision;
358
+ if (expectedRevision !== undefined && expectedRevision !== currentRevision) {
359
+ throw new GuardStateStaleError();
360
+ }
361
+ const nextRevision = currentRevision + 1;
362
+ const contentFingerprint = fingerprintGuardConfig(guardConfig);
363
+ const { contracts = [], ...rulesConfig } = guardConfig;
364
+ atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify({ ...rulesConfig, revision: nextRevision, contentFingerprint }, null, 2)}\n`);
365
+ atomicWriteFile(resolve(root, GUARD_CONTRACTS_FILE), `${JSON.stringify({ version: 1, contracts }, null, 2)}\n`);
366
+ }
367
+ function recoverGuardPolicyTransaction(root) {
368
+ const path = resolve(root, GUARD_POLICY_TRANSACTION_FILE);
369
+ if (!existsSync(path))
370
+ return;
371
+ const value = readJson(path);
372
+ if (!isGuardPolicyTransaction(value)) {
373
+ throw new GuardConfigError(GUARD_POLICY_TRANSACTION_FILE);
374
+ }
375
+ writeGuardConfigUnlocked(root, value.config);
376
+ if (value.proposals) {
377
+ writeGuardArtifactUnlocked(root, GUARD_PROPOSALS_FILE, value.proposals);
378
+ }
379
+ unlinkSync(path);
380
+ }
381
+ function loadGuardConfigUnlocked(root) {
382
+ recoverGuardPolicyTransaction(root);
383
+ return readGuardConfigFilesUnlocked(root);
384
+ }
385
+ export function loadGuardConfig(root) {
386
+ if (!existsSync(resolve(root, GUARD_DIR)))
387
+ return loadGuardConfigUnlocked(root);
388
+ return withGuardStateLock(root, () => loadGuardConfigUnlocked(root));
389
+ }
390
+ function loadGuardProposalsUnlocked(root) {
391
+ const path = resolve(root, GUARD_PROPOSALS_FILE);
392
+ if (!existsSync(path))
393
+ return undefined;
394
+ const value = readJson(path);
395
+ if (value === undefined || !isGuardProposalFile(value)) {
396
+ throw new GuardConfigError(GUARD_PROPOSALS_FILE);
397
+ }
398
+ return value;
399
+ }
126
400
  export function loadGuardProposals(root) {
127
- const value = readJson(resolve(root, GUARD_PROPOSALS_FILE));
128
- return isGuardProposalFile(value) ? value : undefined;
401
+ if (!existsSync(resolve(root, GUARD_DIR)))
402
+ return loadGuardProposalsUnlocked(root);
403
+ return withGuardStateLock(root, () => {
404
+ recoverGuardPolicyTransaction(root);
405
+ return loadGuardProposalsUnlocked(root);
406
+ });
129
407
  }
130
408
  export function isGuardAgentConfig(value) {
131
409
  return guardAgentConfigSchema.safeParse(value).success;
132
410
  }
133
411
  export function loadGuardAgentConfig(root) {
134
- const value = readJson(resolve(root, GUARD_AGENT_FILE));
135
- return isGuardAgentConfig(value) ? value : defaultGuardAgentConfig;
412
+ const path = resolve(root, GUARD_AGENT_FILE);
413
+ if (!existsSync(path))
414
+ return defaultGuardAgentConfig;
415
+ const value = readJson(path);
416
+ if (!isGuardAgentConfig(value))
417
+ throw new GuardConfigError(GUARD_AGENT_FILE);
418
+ return value;
136
419
  }
137
420
  export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
138
421
  writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
139
422
  }
140
- export function loadGuardArtifact(root, relativePath) {
141
- return readJson(resolve(root, relativePath));
142
- }
143
423
  export function loadBaseline(root) {
144
- const value = readJson(resolve(root, GUARD_BASELINE_FILE));
145
- if (!Array.isArray(value))
424
+ const path = resolve(root, GUARD_BASELINE_FILE);
425
+ if (!existsSync(path))
146
426
  return new Set();
427
+ const value = readJson(path);
428
+ if (!Array.isArray(value))
429
+ throw new GuardConfigError(GUARD_BASELINE_FILE);
147
430
  return new Set(value.filter((item) => typeof item === 'string'));
148
431
  }
149
432
  export function updateBaseline(root, options = {}) {
150
- const next = loadBaseline(root);
151
- for (const fingerprint of options.add ?? [])
152
- next.add(fingerprint);
153
- for (const fingerprint of options.remove ?? [])
154
- next.delete(fingerprint);
155
- const fingerprints = [...next].sort();
156
- const metadataPath = resolve(root, GUARD_BASELINE_META_FILE);
157
- const previous = readJson(metadataPath);
158
- const history = previous !== null &&
159
- typeof previous === 'object' &&
160
- Array.isArray(previous.decisions)
161
- ? previous.decisions
162
- : [];
163
- const decision = {
164
- at: new Date().toISOString(),
165
- action: (options.add?.length ?? 0) > 0 ? 'accept' : 'remove',
166
- fingerprints: [...(options.add ?? []), ...(options.remove ?? [])],
167
- ...(options.reason?.trim() ? { reason: options.reason.trim() } : {}),
168
- };
169
- atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
170
- atomicWriteFile(metadataPath, `${JSON.stringify({
171
- ...(previous !== null && typeof previous === 'object' ? previous : {}),
172
- version: 1,
173
- generatedAt: new Date().toISOString(),
174
- findings: fingerprints.length,
175
- decisions: [...history, decision],
176
- }, null, 2)}\n`);
177
- return next;
433
+ if ((options.add?.length ?? 0) > 0 || (options.remove?.length ?? 0) > 0) {
434
+ if (!options.reason?.trim())
435
+ throw new GuardBaselineReasonError();
436
+ }
437
+ return withGuardStateLock(root, () => {
438
+ const next = loadBaseline(root);
439
+ for (const fingerprint of options.add ?? [])
440
+ next.add(fingerprint);
441
+ for (const fingerprint of options.remove ?? [])
442
+ next.delete(fingerprint);
443
+ const fingerprints = [...next].sort();
444
+ const metadataPath = resolve(root, GUARD_BASELINE_META_FILE);
445
+ const previous = readJson(metadataPath);
446
+ const history = previous !== null &&
447
+ typeof previous === 'object' &&
448
+ Array.isArray(previous.decisions)
449
+ ? previous.decisions
450
+ : [];
451
+ const decision = {
452
+ at: new Date().toISOString(),
453
+ action: (options.add?.length ?? 0) > 0 ? 'accept' : 'remove',
454
+ fingerprints: [...(options.add ?? []), ...(options.remove ?? [])],
455
+ ...(options.reason?.trim() ? { reason: options.reason.trim() } : {}),
456
+ };
457
+ atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
458
+ atomicWriteFile(metadataPath, `${JSON.stringify({
459
+ ...(previous !== null && typeof previous === 'object' ? previous : {}),
460
+ version: 1,
461
+ generatedAt: new Date().toISOString(),
462
+ findings: fingerprints.length,
463
+ decisions: [...history, decision],
464
+ }, null, 2)}\n`);
465
+ return next;
466
+ });
178
467
  }
179
- export function writeGuardConfig(root, guardConfig) {
180
- mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
181
- const { contracts = [], ...rulesConfig } = guardConfig;
182
- atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify(rulesConfig, null, 2)}\n`);
183
- atomicWriteFile(resolve(root, GUARD_CONTRACTS_FILE), `${JSON.stringify({ version: 1, contracts }, null, 2)}\n`);
468
+ export function writeGuardConfig(root, guardConfig, options = {}) {
469
+ withGuardStateLock(root, () => writeGuardConfigUnlocked(root, guardConfig, options), options);
184
470
  }
185
471
  export function writeGuardProposals(root, proposals) {
186
472
  writeGuardArtifact(root, GUARD_PROPOSALS_FILE, proposals);
187
473
  }
188
- export function recordGuardProposalDecision(root, decisions) {
189
- const proposals = loadGuardProposals(root);
474
+ function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
475
+ const proposals = loadGuardProposalsUnlocked(root);
190
476
  if (!proposals || decisions.length === 0)
191
477
  return;
192
478
  const decidedAt = new Date().toISOString();
479
+ const commit = (() => {
480
+ try {
481
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
482
+ .toString()
483
+ .trim();
484
+ }
485
+ catch {
486
+ return undefined;
487
+ }
488
+ })();
193
489
  const nextDecisions = decisions.map((decision) => ({
194
490
  ...decision,
195
491
  decidedAt,
492
+ source: options.source,
493
+ ...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
494
+ ...(commit ? { commit } : {}),
196
495
  ...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
197
496
  ...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
198
497
  ...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
199
498
  }));
200
- writeGuardProposals(root, {
499
+ return {
201
500
  ...proposals,
202
501
  decisions: [...(proposals.decisions ?? []), ...nextDecisions],
502
+ };
503
+ }
504
+ function appendGuardProposalDecisionsUnlocked(root, decisions, options) {
505
+ const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
506
+ if (!proposals)
507
+ return;
508
+ writeGuardArtifactUnlocked(root, GUARD_PROPOSALS_FILE, proposals);
509
+ }
510
+ export function recordGuardProposalDecision(root, decisions, options = { source: 'external' }) {
511
+ withGuardStateLock(root, () => {
512
+ appendGuardProposalDecisionsUnlocked(root, decisions, options);
513
+ });
514
+ }
515
+ export function applyGuardProposalDecision(root, guardConfig, decisions, options) {
516
+ withGuardStateLock(root, () => {
517
+ const proposals = buildGuardProposalWithDecisionsUnlocked(root, decisions, options);
518
+ writeGuardArtifactUnlocked(root, GUARD_POLICY_TRANSACTION_FILE, {
519
+ version: 1,
520
+ createdAt: new Date().toISOString(),
521
+ config: guardConfig,
522
+ ...(proposals ? { proposals } : {}),
523
+ });
524
+ writeGuardConfigUnlocked(root, guardConfig);
525
+ if (proposals)
526
+ writeGuardArtifactUnlocked(root, GUARD_PROPOSALS_FILE, proposals);
527
+ unlinkSync(resolve(root, GUARD_POLICY_TRANSACTION_FILE));
203
528
  });
204
529
  }
530
+ export function validateGuardProposalApproval(policy, proposals) {
531
+ if (policy?.mode !== 'protected' || !policy.requireDistinctActor)
532
+ return undefined;
533
+ const actor = process.env.GUARD_APPROVER?.trim();
534
+ if (!actor) {
535
+ return 'Protected Guard policy requires GUARD_APPROVER for a distinct approval actor.';
536
+ }
537
+ if (proposals?.generatedBy && proposals.generatedBy === actor) {
538
+ return 'The proposal author and approval actor must be different.';
539
+ }
540
+ return undefined;
541
+ }
542
+ export function getPendingGuardProposals(proposals) {
543
+ if (!proposals)
544
+ return [];
545
+ const decisions = new Map();
546
+ for (const decision of proposals.decisions ?? []) {
547
+ const current = decisions.get(`${decision.type}:${decision.id}`);
548
+ if (!current || decision.decidedAt >= current.decidedAt) {
549
+ decisions.set(`${decision.type}:${decision.id}`, decision);
550
+ }
551
+ }
552
+ const matchesCurrentProposal = (decision) => decision !== undefined &&
553
+ (proposals.proposalId === undefined || decision.proposalId === proposals.proposalId) &&
554
+ (proposals.contentFingerprint === undefined ||
555
+ decision.proposalFingerprint === proposals.contentFingerprint) &&
556
+ (proposals.revision === undefined || decision.revision === proposals.revision);
557
+ return [
558
+ ...proposals.rules
559
+ .filter((rule) => rule.status === 'proposed' && !matchesCurrentProposal(decisions.get(`rule:${rule.id}`)))
560
+ .map((rule) => ({ id: rule.id, type: 'rule' })),
561
+ ...proposals.contracts
562
+ .filter((contract) => contract.status === 'proposed' &&
563
+ !matchesCurrentProposal(decisions.get(`contract:${contract.id}`)))
564
+ .map((contract) => ({ id: contract.id, type: 'contract' })),
565
+ ];
566
+ }
205
567
  export function buildGuardProposals(model, guardConfig) {
206
568
  const capabilities = Object.keys(model.capabilities);
207
569
  const questions = [];
@@ -249,6 +611,9 @@ export function buildGuardProposals(model, guardConfig) {
249
611
  const proposal = {
250
612
  version: 1,
251
613
  generatedAt: new Date().toISOString(),
614
+ ...(process.env.GUARD_PROPOSER?.trim()
615
+ ? { generatedBy: process.env.GUARD_PROPOSER.trim() }
616
+ : {}),
252
617
  rules: guardConfig.rules.filter((rule) => rule.status === 'proposed'),
253
618
  contracts: [
254
619
  ...(guardConfig.contracts ?? []).filter((contract) => contract.status === 'proposed'),
@@ -347,14 +712,19 @@ export function buildConventionsMemory(model) {
347
712
  }
348
713
  export function buildGeneratedGuardConfig(model) {
349
714
  const isPersistenceImport = (importPath) => /(?:^|\/)(?:database|db)(?:\/|$)/i.test(importPath) ||
350
- /^(?:@prisma\codapult-guardent|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
715
+ /^(?:@prisma\/client|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
716
+ const moduleReferences = (module) => [
717
+ ...module.imports,
718
+ ...module.exports,
719
+ ...module.dynamicImports,
720
+ ];
351
721
  const persistenceImports = [
352
- ...new Set(model.modules.flatMap((module) => module.imports).filter(isPersistenceImport)),
722
+ ...new Set(model.modules.flatMap((module) => moduleReferences(module)).filter(isPersistenceImport)),
353
723
  ].sort();
354
724
  const clientPersistenceFiles = model.insights.boundaries
355
725
  .find((boundary) => boundary.kind === 'client')
356
726
  ?.files.filter((file) => model.modules.some((module) => module.path === file &&
357
- module.imports.some((importPath) => persistenceImports.includes(importPath)))) ?? [];
727
+ moduleReferences(module).some((importPath) => persistenceImports.includes(importPath)))) ?? [];
358
728
  const proposedRules = clientPersistenceFiles.length > 0
359
729
  ? [
360
730
  {
@@ -372,23 +742,37 @@ export function buildGeneratedGuardConfig(model) {
372
742
  return { ...defaultGuardConfig, rules: proposedRules };
373
743
  }
374
744
  export function writeGuardMemory(root, model) {
375
- writeGuardArtifact(root, GUARD_ARCHITECTURE_FILE, buildArchitectureMemory(model));
376
- writeGuardArtifact(root, GUARD_CONVENTIONS_FILE, buildConventionsMemory(model));
745
+ withGuardStateLock(root, () => {
746
+ writeGuardArtifact(root, GUARD_ARCHITECTURE_FILE, buildArchitectureMemory(model));
747
+ writeGuardArtifact(root, GUARD_CONVENTIONS_FILE, buildConventionsMemory(model));
748
+ });
377
749
  }
378
750
  export function writeBaseline(root, findings, model) {
379
- mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
380
- const fingerprints = [...new Set(findings.map((finding) => finding.fingerprint))].sort();
381
- atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
382
- atomicWriteFile(resolve(root, GUARD_BASELINE_META_FILE), `${JSON.stringify({
383
- version: 1,
384
- generatedAt: new Date().toISOString(),
385
- findings: fingerprints.length,
386
- ...(model ? { projectFingerprint: fingerprintProjectModel(model) } : {}),
387
- guardSchemaVersion: 1,
388
- purpose: 'Initial Guard state; findings are suppressed unless they change fingerprint.',
389
- }, null, 2)}\n`);
751
+ withGuardStateLock(root, () => {
752
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
753
+ const fingerprints = [...new Set(findings.map((finding) => finding.fingerprint))].sort();
754
+ atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
755
+ atomicWriteFile(resolve(root, GUARD_BASELINE_META_FILE), `${JSON.stringify({
756
+ version: 1,
757
+ generatedAt: new Date().toISOString(),
758
+ findings: fingerprints.length,
759
+ ...(model ? { projectFingerprint: fingerprintProjectModel(model) } : {}),
760
+ guardSchemaVersion: 1,
761
+ purpose: 'Initial Guard state; findings are suppressed unless they change fingerprint.',
762
+ }, null, 2)}\n`);
763
+ });
390
764
  }
391
765
  export function loadProjectModel(root) {
766
+ const current = readJson(resolve(root, GUARD_STATE_CURRENT_FILE));
767
+ if (current !== undefined &&
768
+ typeof current === 'object' &&
769
+ safeGeneration(current.generation)) {
770
+ const generated = readJson(resolve(root, GUARD_STATE_GENERATIONS_DIR, current.generation, 'project.json'));
771
+ if (generated !== undefined &&
772
+ typeof generated === 'object' &&
773
+ generated.version === 1)
774
+ return generated;
775
+ }
392
776
  const value = readJson(resolve(root, GUARD_PROJECT_FILE));
393
777
  if (value === null ||
394
778
  typeof value !== 'object' ||
@@ -398,8 +782,40 @@ export function loadProjectModel(root) {
398
782
  return value;
399
783
  }
400
784
  export function writeProjectModel(root, model) {
401
- mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
402
- atomicWriteFile(resolve(root, GUARD_PROJECT_FILE), `${JSON.stringify(model, null, 2)}\n`);
785
+ withGuardStateLock(root, () => {
786
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
787
+ atomicWriteFile(resolve(root, GUARD_PROJECT_FILE), `${JSON.stringify(model, null, 2)}\n`);
788
+ });
789
+ }
790
+ /**
791
+ * Persist all derived project facts as one generation. Legacy root-level files remain as
792
+ * compatibility mirrors, while readers that understand generations always see a coherent set.
793
+ */
794
+ export function writeProjectState(root, model, options = {}) {
795
+ return withGuardStateLock(root, () => {
796
+ const generationsRoot = resolve(root, GUARD_STATE_GENERATIONS_DIR);
797
+ mkdirSync(generationsRoot, { recursive: true });
798
+ const generation = `${Date.now()}-${randomUUID()}`;
799
+ const temporaryRoot = resolve(generationsRoot, `.tmp-${process.pid}-${randomUUID()}`);
800
+ mkdirSync(temporaryRoot, { recursive: true });
801
+ const derived = {
802
+ project: model,
803
+ architecture: buildArchitectureMemory(model),
804
+ conventions: buildConventionsMemory(model),
805
+ };
806
+ for (const [name, value] of Object.entries(derived)) {
807
+ writeFileSync(resolve(temporaryRoot, `${name}.json`), `${JSON.stringify(value, null, 2)}\n`);
808
+ }
809
+ writeFileSync(resolve(temporaryRoot, 'manifest.json'), `${JSON.stringify({ version: 1, generation, projectFingerprint: fingerprintProjectModel(model) }, null, 2)}\n`);
810
+ renameSync(temporaryRoot, resolve(generationsRoot, generation));
811
+ atomicWriteFile(resolve(root, GUARD_STATE_CURRENT_FILE), `${JSON.stringify({ version: 1, generation, projectFingerprint: fingerprintProjectModel(model) }, null, 2)}\n`);
812
+ atomicWriteFile(resolve(root, GUARD_PROJECT_FILE), `${JSON.stringify(model, null, 2)}\n`);
813
+ atomicWriteFile(resolve(root, GUARD_ARCHITECTURE_FILE), `${JSON.stringify(derived.architecture, null, 2)}\n`);
814
+ atomicWriteFile(resolve(root, GUARD_CONVENTIONS_FILE), `${JSON.stringify(derived.conventions, null, 2)}\n`);
815
+ // Snapshot persistence is defined below the generation writer to keep the public API grouped.
816
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
817
+ return writeProjectSnapshot(root, model);
818
+ }, options);
403
819
  }
404
820
  function projectRevision(root) {
405
821
  try {
@@ -413,16 +829,61 @@ function projectRevision(root) {
413
829
  }
414
830
  }
415
831
  export function writeProjectSnapshot(root, model) {
416
- const baseRevision = projectRevision(root);
417
- const revision = model.git.dirty && baseRevision !== 'working-tree'
418
- ? `${baseRevision}-working-tree-${createHash('sha256')
419
- .update(JSON.stringify(model))
420
- .digest('hex')
421
- .slice(0, 12)}`
422
- : baseRevision;
423
- mkdirSync(resolve(root, GUARD_HISTORY_DIR), { recursive: true });
424
- atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
425
- return revision;
832
+ return withGuardStateLock(root, () => {
833
+ const baseRevision = projectRevision(root);
834
+ const revision = model.git.dirty && baseRevision !== 'working-tree'
835
+ ? `${baseRevision}-working-tree-${createHash('sha256')
836
+ .update(JSON.stringify(model))
837
+ .digest('hex')
838
+ .slice(0, 12)}`
839
+ : baseRevision;
840
+ mkdirSync(resolve(root, GUARD_HISTORY_DIR), { recursive: true });
841
+ atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
842
+ return revision;
843
+ });
844
+ }
845
+ function isProjectPath(root, value) {
846
+ const normalized = value.replace(/\\/g, '/');
847
+ if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
848
+ return false;
849
+ }
850
+ try {
851
+ const projectRoot = `${realpathSync(root)}${sep}`;
852
+ const targetPath = resolve(root, normalized);
853
+ let target;
854
+ try {
855
+ target = realpathSync(targetPath);
856
+ }
857
+ catch {
858
+ target = realpathSync(dirname(targetPath));
859
+ }
860
+ return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
861
+ }
862
+ catch {
863
+ return false;
864
+ }
865
+ }
866
+ export function loadGuardArtifact(root, relativePath) {
867
+ if (!isProjectPath(root, relativePath))
868
+ return undefined;
869
+ const generatedName = relativePath === GUARD_PROJECT_FILE
870
+ ? 'project.json'
871
+ : relativePath === GUARD_ARCHITECTURE_FILE
872
+ ? 'architecture.json'
873
+ : relativePath === GUARD_CONVENTIONS_FILE
874
+ ? 'conventions.json'
875
+ : undefined;
876
+ if (generatedName) {
877
+ const current = readJson(resolve(root, GUARD_STATE_CURRENT_FILE));
878
+ if (current !== undefined &&
879
+ typeof current === 'object' &&
880
+ safeGeneration(current.generation)) {
881
+ const generated = readJson(resolve(root, GUARD_STATE_GENERATIONS_DIR, current.generation, generatedName));
882
+ if (generated !== undefined)
883
+ return generated;
884
+ }
885
+ }
886
+ return readJson(resolve(root, relativePath));
426
887
  }
427
888
  export function validateGuardContracts(root, contracts = []) {
428
889
  const issues = [];
@@ -445,8 +906,18 @@ export function validateGuardContracts(root, contracts = []) {
445
906
  message: 'Required-call contracts need at least one mustCall pattern.',
446
907
  });
447
908
  }
909
+ if (contract.kind === 'package-boundary' &&
910
+ ((contract.fromPackages?.length ?? 0) === 0 ||
911
+ (contract.mustNotImportPackages?.length ?? 0) === 0)) {
912
+ issues.push({
913
+ contractId: contract.id,
914
+ field: 'definition',
915
+ value: contract.kind,
916
+ message: 'Package-boundary contracts need fromPackages and mustNotImportPackages patterns.',
917
+ });
918
+ }
448
919
  for (const scope of contract.scope ?? []) {
449
- if (!existsSync(resolve(root, scope))) {
920
+ if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
450
921
  issues.push({
451
922
  contractId: contract.id,
452
923
  field: 'scope',
@@ -456,7 +927,7 @@ export function validateGuardContracts(root, contracts = []) {
456
927
  }
457
928
  }
458
929
  for (const entrypoint of contract.entrypoints ?? []) {
459
- if (!existsSync(resolve(root, entrypoint))) {
930
+ if (!isProjectPath(root, entrypoint) || !existsSync(resolve(root, entrypoint))) {
460
931
  issues.push({
461
932
  contractId: contract.id,
462
933
  field: 'scope',
@@ -466,7 +937,7 @@ export function validateGuardContracts(root, contracts = []) {
466
937
  }
467
938
  }
468
939
  for (const excluded of contract.exclude ?? []) {
469
- if (!existsSync(resolve(root, excluded))) {
940
+ if (!isProjectPath(root, excluded) || !existsSync(resolve(root, excluded))) {
470
941
  issues.push({
471
942
  contractId: contract.id,
472
943
  field: 'scope',
@@ -476,7 +947,7 @@ export function validateGuardContracts(root, contracts = []) {
476
947
  }
477
948
  }
478
949
  for (const reference of contract.references ?? []) {
479
- if (!existsSync(resolve(root, reference))) {
950
+ if (!isProjectPath(root, reference) || !existsSync(resolve(root, reference))) {
480
951
  issues.push({
481
952
  contractId: contract.id,
482
953
  field: 'reference',
@@ -488,6 +959,51 @@ export function validateGuardContracts(root, contracts = []) {
488
959
  }
489
960
  return issues;
490
961
  }
962
+ export function validateGuardBudgets(root, budgets = []) {
963
+ const issues = [];
964
+ for (const budget of budgets) {
965
+ if (!budget.reason.trim()) {
966
+ issues.push({
967
+ contractId: `budget:${budget.id}`,
968
+ field: 'definition',
969
+ value: budget.id,
970
+ message: 'Budgets require a written reason.',
971
+ });
972
+ }
973
+ for (const rawScope of budget.scope) {
974
+ const scope = rawScope.replace(/\*+$/, '') || '.';
975
+ if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
976
+ issues.push({
977
+ contractId: `budget:${budget.id}`,
978
+ field: 'scope',
979
+ value: rawScope,
980
+ message: `Budget scope does not exist inside the project: ${rawScope}`,
981
+ });
982
+ }
983
+ }
984
+ }
985
+ return issues;
986
+ }
987
+ export function validateGuardPolicy(root, guardConfig) {
988
+ const issues = [];
989
+ for (const rule of guardConfig.rules) {
990
+ for (const file of rule.files ?? []) {
991
+ if (!isProjectPath(root, file)) {
992
+ issues.push({
993
+ contractId: `rule:${rule.id}`,
994
+ field: 'files',
995
+ value: file,
996
+ message: `Rule file scope must stay inside the project: ${file}`,
997
+ });
998
+ }
999
+ }
1000
+ }
1001
+ return [
1002
+ ...issues,
1003
+ ...validateGuardContracts(root, guardConfig.contracts ?? []),
1004
+ ...validateGuardBudgets(root, guardConfig.budgets ?? []),
1005
+ ];
1006
+ }
491
1007
  function isSourceFile(file) {
492
1008
  return /\.(?:ts|tsx|js|jsx)$/.test(file) && !/\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/.test(file);
493
1009
  }
@@ -541,6 +1057,32 @@ function importLine(sourceFile, importPath) {
541
1057
  });
542
1058
  return dynamicImport?.getStartLineNumber() ?? 1;
543
1059
  }
1060
+ function moduleImportReferences(module) {
1061
+ const sources = [...new Set([...module.imports, ...module.exports, ...module.dynamicImports])];
1062
+ return sources.map((source) => ({ source, resolved: module.resolvedImportMap?.[source] }));
1063
+ }
1064
+ function packageSelectorMatches(value, selector) {
1065
+ if (!value)
1066
+ return false;
1067
+ return selector.endsWith('*') ? value.startsWith(selector.slice(0, -1)) : value === selector;
1068
+ }
1069
+ function packageForFile(model, file) {
1070
+ const packageInfo = model.project.workspacePackages
1071
+ .filter((workspace) => workspace.path === '.' ||
1072
+ file === `${workspace.path}/package.json` ||
1073
+ file.startsWith(`${workspace.path}/`))
1074
+ .sort((left, right) => right.path.length - left.path.length)[0] ?? null;
1075
+ return packageInfo === null ? undefined : (packageInfo.name ?? packageInfo.path);
1076
+ }
1077
+ function packageForImport(model, reference) {
1078
+ const resolvedPackage = reference.resolved
1079
+ ? packageForFile(model, reference.resolved)
1080
+ : undefined;
1081
+ if (resolvedPackage)
1082
+ return resolvedPackage;
1083
+ return model.project.workspacePackages.find((workspace) => workspace.name === reference.source ||
1084
+ (workspace.name !== undefined && reference.source.startsWith(`${workspace.name}/`)))?.name;
1085
+ }
544
1086
  function scanFile(root, file, rules, module, astProject) {
545
1087
  if (!module)
546
1088
  return [];
@@ -555,26 +1097,25 @@ function scanFile(root, file, rules, module, astProject) {
555
1097
  }
556
1098
  }
557
1099
  const findings = [];
558
- const importPaths = [
559
- ...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
560
- ];
561
- for (const importPath of importPaths) {
562
- const line = sourceFile ? importLine(sourceFile, importPath) : 1;
1100
+ for (const reference of moduleImportReferences(module)) {
1101
+ const line = sourceFile ? importLine(sourceFile, reference.source) : 1;
563
1102
  for (const rule of rules) {
564
1103
  if (rule.status === 'proposed' || !ruleAppliesToFile(rule, file))
565
1104
  continue;
566
1105
  if (rule.kind === 'client-forbidden-import' && !isClient)
567
1106
  continue;
568
- if (!rule.patterns.some((pattern) => patternMatches(importPath, pattern)))
1107
+ if (!rule.patterns.some((pattern) => patternMatches(reference.source, pattern) ||
1108
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))
569
1109
  continue;
570
1110
  findings.push({
571
1111
  ruleId: rule.id,
572
1112
  severity: rule.severity,
573
1113
  file,
574
1114
  line,
575
- importPath,
1115
+ importPath: reference.source,
1116
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
576
1117
  message: rule.description,
577
- fingerprint: `${rule.id}|${file}|${importPath}`,
1118
+ fingerprint: `${rule.id}|${file}|${reference.source}|${reference.resolved ?? ''}`,
578
1119
  });
579
1120
  }
580
1121
  }
@@ -589,22 +1130,25 @@ function scanContracts(root, contracts, changed) {
589
1130
  const severity = contract.severity ?? 'error';
590
1131
  for (const module of model.modules.filter((item) => (!changed || changed.has(item.path)) && contractAppliesToFile(contract, item.path))) {
591
1132
  if (contract.kind === 'import-boundary') {
592
- for (const importPath of module.imports) {
593
- if (contract.mustNotImport?.some((pattern) => patternMatches(importPath, pattern))) {
1133
+ for (const reference of moduleImportReferences(module)) {
1134
+ if (contract.mustNotImport?.some((pattern) => patternMatches(reference.source, pattern) ||
1135
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern)))) {
594
1136
  findings.push({
595
1137
  ruleId: `contract:${contract.id}`,
596
1138
  severity,
597
1139
  file: module.path,
598
1140
  line: 1,
599
- importPath,
1141
+ importPath: reference.source,
1142
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
600
1143
  message: contract.statement,
601
- fingerprint: `contract-import|${contract.id}|${module.path}|${importPath}`,
1144
+ fingerprint: `contract-import|${contract.id}|${module.path}|${reference.source}|${reference.resolved ?? ''}`,
602
1145
  });
603
1146
  }
604
1147
  }
605
1148
  if (contract.mustImport !== undefined &&
606
1149
  contract.mustImport.length > 0 &&
607
- !module.imports.some((importPath) => contract.mustImport?.some((pattern) => patternMatches(importPath, pattern)))) {
1150
+ !moduleImportReferences(module).some((reference) => contract.mustImport?.some((pattern) => patternMatches(reference.source, pattern) ||
1151
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))) {
608
1152
  findings.push({
609
1153
  ruleId: `contract:${contract.id}`,
610
1154
  severity,
@@ -630,6 +1174,28 @@ function scanContracts(root, contracts, changed) {
630
1174
  fingerprint: `contract-required-call|${contract.id}|${module.path}`,
631
1175
  });
632
1176
  }
1177
+ if (contract.kind === 'package-boundary') {
1178
+ const sourcePackage = packageForFile(model, module.path);
1179
+ if (!contract.fromPackages?.some((selector) => packageSelectorMatches(sourcePackage, selector))) {
1180
+ continue;
1181
+ }
1182
+ for (const reference of moduleImportReferences(module)) {
1183
+ const targetPackage = packageForImport(model, reference);
1184
+ if (!contract.mustNotImportPackages?.some((selector) => packageSelectorMatches(targetPackage, selector))) {
1185
+ continue;
1186
+ }
1187
+ findings.push({
1188
+ ruleId: `contract:${contract.id}`,
1189
+ severity,
1190
+ file: module.path,
1191
+ line: 1,
1192
+ importPath: reference.source,
1193
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
1194
+ message: contract.statement,
1195
+ fingerprint: `contract-package|${contract.id}|${module.path}|${reference.source}|${targetPackage ?? ''}`,
1196
+ });
1197
+ }
1198
+ }
633
1199
  }
634
1200
  }
635
1201
  return findings;
@@ -657,6 +1223,30 @@ function changedFiles(root) {
657
1223
  return undefined;
658
1224
  }
659
1225
  }
1226
+ function changedImpactScope(model, changed) {
1227
+ if (!changed)
1228
+ return undefined;
1229
+ const graph = buildModuleTargetGraph(model.modules);
1230
+ const reverse = new Map();
1231
+ for (const [from, targets] of graph) {
1232
+ for (const target of targets)
1233
+ reverse.set(target, [...(reverse.get(target) ?? []), from]);
1234
+ }
1235
+ const scope = new Set(changed);
1236
+ const queue = [...changed];
1237
+ while (queue.length > 0) {
1238
+ const current = queue.shift();
1239
+ if (!current)
1240
+ continue;
1241
+ for (const dependent of reverse.get(current) ?? []) {
1242
+ if (!scope.has(dependent)) {
1243
+ scope.add(dependent);
1244
+ queue.push(dependent);
1245
+ }
1246
+ }
1247
+ }
1248
+ return scope;
1249
+ }
660
1250
  function architectureInsightFindings(root, changed) {
661
1251
  const model = discoverProject(root);
662
1252
  const findings = model.insights.cycles
@@ -677,7 +1267,9 @@ function architectureInsightFindings(root, changed) {
677
1267
  for (const module of model.modules) {
678
1268
  if (!clientFiles.has(module.path))
679
1269
  continue;
680
- for (const importPath of module.imports.filter((value) => serverOnlyImports.test(value))) {
1270
+ for (const importPath of [
1271
+ ...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
1272
+ ].filter((value) => serverOnlyImports.test(value))) {
681
1273
  if (changed && !changed.has(module.path))
682
1274
  continue;
683
1275
  findings.push({
@@ -709,6 +1301,49 @@ function architectureInsightFindings(root, changed) {
709
1301
  }
710
1302
  return findings;
711
1303
  }
1304
+ function budgetMatchesFile(budget, file) {
1305
+ return budget.scope.some((scope) => scope.endsWith('*')
1306
+ ? file.startsWith(scope.slice(0, -1))
1307
+ : file === scope || file.startsWith(`${scope}/`));
1308
+ }
1309
+ function budgetValue(root, metric, file, module) {
1310
+ if (metric === 'bytes')
1311
+ return file.bytes;
1312
+ if (metric === 'imports') {
1313
+ return module
1314
+ ? new Set([...module.imports, ...module.exports, ...module.dynamicImports]).size
1315
+ : 0;
1316
+ }
1317
+ try {
1318
+ return readFileSync(resolve(root, file.path), 'utf8').split(/\r?\n/).length;
1319
+ }
1320
+ catch {
1321
+ return 0;
1322
+ }
1323
+ }
1324
+ function scanBudgets(root, model, budgets, changed) {
1325
+ const modules = new Map(model.modules.map((module) => [module.path, module]));
1326
+ return budgets
1327
+ .filter((budget) => budget.status !== 'proposed')
1328
+ .flatMap((budget) => model.files
1329
+ .filter((file) => budgetMatchesFile(budget, file.path) && (!changed || changed.has(file.path)))
1330
+ .flatMap((file) => {
1331
+ const value = budgetValue(root, budget.metric, file, modules.get(file.path));
1332
+ if (value <= budget.limit)
1333
+ return [];
1334
+ return [
1335
+ {
1336
+ ruleId: `budget:${budget.id}`,
1337
+ severity: budget.severity,
1338
+ file: file.path,
1339
+ line: 1,
1340
+ importPath: `budget:${budget.metric}`,
1341
+ message: `${budget.description} (${value} ${budget.metric}; limit ${budget.limit}).`,
1342
+ fingerprint: `budget|${budget.id}|${file.path}|${budget.metric}`,
1343
+ },
1344
+ ];
1345
+ }));
1346
+ }
712
1347
  function isSafeReviewFile(file) {
713
1348
  return !(file === '.env' ||
714
1349
  file.startsWith('.env.') ||
@@ -716,25 +1351,7 @@ function isSafeReviewFile(file) {
716
1351
  /\.(?:pem|key|p12|pfx)$/i.test(file));
717
1352
  }
718
1353
  function isSafeReviewPath(root, file) {
719
- const normalizedFile = file.replace(/\\/g, '/');
720
- if (normalizedFile.startsWith('/') || normalizedFile.split('/').includes('..'))
721
- return false;
722
- try {
723
- const projectRoot = `${realpathSync(root)}${sep}`;
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
- }
733
- return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
734
- }
735
- catch {
736
- return false;
737
- }
1354
+ return isProjectPath(root, file);
738
1355
  }
739
1356
  function parseGitChanges(root, args) {
740
1357
  try {
@@ -780,6 +1397,9 @@ export function redactSensitiveText(value) {
780
1397
  };
781
1398
  replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]');
782
1399
  replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]');
1400
+ replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED JWT]');
1401
+ replace(/\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, '[REDACTED URL CREDENTIALS]@');
1402
+ replace(/\b(?:AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})\b/g, '[REDACTED TOKEN]');
783
1403
  replace(/((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\s*[:=]\s*["']?)[^\s"'`,}]+/gi, '$1[REDACTED]');
784
1404
  return { value: redactedValue, redacted, redactionCount };
785
1405
  }
@@ -898,9 +1518,10 @@ function reviewDiff(root, maxChars, base) {
898
1518
  }
899
1519
  }
900
1520
  export function scanGuard(root, guardConfig, options = {}) {
901
- const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
902
- const files = listSourceFiles(root).filter((file) => !changed || changed.has(file));
903
1521
  const model = discoverProject(root);
1522
+ const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
1523
+ const scope = changedImpactScope(model, changed);
1524
+ const files = listSourceFiles(root).filter((file) => !scope || scope.has(file));
904
1525
  const modules = new Map(model.modules.map((module) => [module.path, module]));
905
1526
  const astProject = (() => {
906
1527
  try {
@@ -912,8 +1533,9 @@ export function scanGuard(root, guardConfig, options = {}) {
912
1533
  })();
913
1534
  const allFindings = [
914
1535
  ...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
915
- ...scanContracts(root, guardConfig.contracts ?? [], changed),
916
- ...(options.includeArchitectureInsights ? architectureInsightFindings(root, changed) : []),
1536
+ ...scanContracts(root, guardConfig.contracts ?? [], scope),
1537
+ ...scanBudgets(root, model, guardConfig.budgets ?? [], scope),
1538
+ ...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
917
1539
  ];
918
1540
  const baseline = options.baseline ?? new Set();
919
1541
  const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
@@ -965,22 +1587,22 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
965
1587
  };
966
1588
  }
967
1589
  export function initializeGuard(root, options = {}) {
968
- if (existsSync(resolve(root, GUARD_BASELINE_FILE)) && !options.force) {
969
- throw new GuardAlreadyInitializedError();
970
- }
971
- const projectModel = discoverProject(root);
972
- const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
973
- ensureGeneratedStateIgnored(root, projectModel);
974
- writeGuardConfig(root, guardConfig);
975
- if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
976
- writeGuardAgentConfig(root);
977
- }
978
- writeGuardMemory(root, projectModel);
979
- writeProjectModel(root, projectModel);
980
- writeProjectSnapshot(root, projectModel);
981
- writeGuardProposals(root, buildGuardProposals(projectModel, guardConfig));
982
- const initialReport = scanGuard(root, guardConfig, { includeArchitectureInsights: true });
983
- writeBaseline(root, initialReport.findings, projectModel);
984
- return { config: guardConfig, report: { ...initialReport, findings: [] } };
985
- }
986
- export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
1590
+ return withGuardStateLock(root, () => {
1591
+ if (existsSync(resolve(root, GUARD_BASELINE_FILE)) && !options.force) {
1592
+ throw new GuardAlreadyInitializedError();
1593
+ }
1594
+ const projectModel = discoverProject(root);
1595
+ const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
1596
+ ensureGeneratedStateIgnored(root, projectModel);
1597
+ writeGuardConfig(root, guardConfig);
1598
+ if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
1599
+ writeGuardAgentConfig(root);
1600
+ }
1601
+ writeProjectState(root, projectModel);
1602
+ writeGuardProposals(root, buildGuardProposals(projectModel, guardConfig));
1603
+ const initialReport = scanGuard(root, guardConfig, { includeArchitectureInsights: true });
1604
+ writeBaseline(root, initialReport.findings, projectModel);
1605
+ return { config: guardConfig, report: { ...initialReport, findings: [] } };
1606
+ }, options);
1607
+ }
1608
+ export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };