@codapult/guard 0.3.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,5 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
+ import { hostname } from 'node:os';
3
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';
@@ -29,10 +30,20 @@ export const GUARD_CONVENTIONS_FILE = `${GUARD_DIR}/conventions.json`;
29
30
  export const GUARD_AGENT_FILE = `${GUARD_DIR}/agent.json`;
30
31
  export const GUARD_CONTRACTS_FILE = `${GUARD_DIR}/contracts.json`;
31
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`;
32
38
  export const defaultGuardConfig = {
33
39
  version: 1,
34
40
  rules: [],
35
41
  contracts: [],
42
+ approval: {
43
+ mode: 'local',
44
+ allowMcpApproval: true,
45
+ requireDistinctActor: false,
46
+ },
36
47
  };
37
48
  export const defaultGuardAgentConfig = {
38
49
  version: 1,
@@ -63,6 +74,142 @@ export class GuardStateBusyError extends Error {
63
74
  this.name = 'GuardStateBusyError';
64
75
  }
65
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
+ }
66
213
  function isGuardConfig(value) {
67
214
  return guardConfigSchema.safeParse(value).success;
68
215
  }
@@ -75,6 +222,12 @@ function readJson(filePath) {
75
222
  }
76
223
  }
77
224
  function atomicWriteFile(path, content) {
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
+ }
78
231
  const lockPath = `${path}.lock`;
79
232
  let lockAcquired = false;
80
233
  try {
@@ -103,14 +256,18 @@ function atomicWriteFile(path, content) {
103
256
  renameSync(temporaryPath, path);
104
257
  }
105
258
  finally {
106
- if (lockAcquired)
107
- unlinkSync(lockPath);
259
+ if (lockAcquired) {
260
+ removeLockFile(lockPath);
261
+ }
108
262
  }
109
263
  }
110
- function writeGuardArtifact(root, relativePath, value) {
264
+ function writeGuardArtifactUnlocked(root, relativePath, value) {
111
265
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
112
266
  atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
113
267
  }
268
+ function writeGuardArtifact(root, relativePath, value) {
269
+ withGuardStateLock(root, () => writeGuardArtifactUnlocked(root, relativePath, value));
270
+ }
114
271
  function hasProjectTool(model, name) {
115
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)));
116
273
  }
@@ -143,12 +300,22 @@ export function isGuardProposalFile(value) {
143
300
  export function fingerprintProjectModel(model) {
144
301
  return createHash('sha256').update(JSON.stringify(model)).digest('hex');
145
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
+ }
146
313
  export function getGuardProposalFreshness(model, proposals) {
147
314
  if (!proposals?.projectFingerprint)
148
315
  return 'unknown';
149
316
  return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
150
317
  }
151
- export function loadGuardConfig(root) {
318
+ function readGuardConfigFilesUnlocked(root) {
152
319
  const rulesPath = resolve(root, GUARD_RULES_FILE);
153
320
  if (!existsSync(rulesPath))
154
321
  return undefined;
@@ -160,89 +327,218 @@ export function loadGuardConfig(root) {
160
327
  const contractFile = readJson(contractsPath);
161
328
  if (!isGuardContractsFile(contractFile))
162
329
  throw new GuardConfigError(GUARD_CONTRACTS_FILE);
163
- return { ...value, contracts: contractFile.contracts };
330
+ return {
331
+ ...value,
332
+ contracts: contractFile.contracts,
333
+ budgets: value.budgets ?? [],
334
+ approval: value.approval ?? defaultGuardConfig.approval,
335
+ };
164
336
  }
165
337
  return {
166
338
  ...value,
167
339
  contracts: value.contracts ?? [],
340
+ budgets: value.budgets ?? [],
341
+ approval: value.approval ?? defaultGuardConfig.approval,
168
342
  };
169
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
+ }
170
400
  export function loadGuardProposals(root) {
171
- const value = readJson(resolve(root, GUARD_PROPOSALS_FILE));
172
- 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
+ });
173
407
  }
174
408
  export function isGuardAgentConfig(value) {
175
409
  return guardAgentConfigSchema.safeParse(value).success;
176
410
  }
177
411
  export function loadGuardAgentConfig(root) {
178
- const value = readJson(resolve(root, GUARD_AGENT_FILE));
179
- 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;
180
419
  }
181
420
  export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
182
421
  writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
183
422
  }
184
423
  export function loadBaseline(root) {
185
- const value = readJson(resolve(root, GUARD_BASELINE_FILE));
186
- if (!Array.isArray(value))
424
+ const path = resolve(root, GUARD_BASELINE_FILE);
425
+ if (!existsSync(path))
187
426
  return new Set();
427
+ const value = readJson(path);
428
+ if (!Array.isArray(value))
429
+ throw new GuardConfigError(GUARD_BASELINE_FILE);
188
430
  return new Set(value.filter((item) => typeof item === 'string'));
189
431
  }
190
432
  export function updateBaseline(root, options = {}) {
191
- const next = loadBaseline(root);
192
- for (const fingerprint of options.add ?? [])
193
- next.add(fingerprint);
194
- for (const fingerprint of options.remove ?? [])
195
- next.delete(fingerprint);
196
- const fingerprints = [...next].sort();
197
- const metadataPath = resolve(root, GUARD_BASELINE_META_FILE);
198
- const previous = readJson(metadataPath);
199
- const history = previous !== null &&
200
- typeof previous === 'object' &&
201
- Array.isArray(previous.decisions)
202
- ? previous.decisions
203
- : [];
204
- const decision = {
205
- at: new Date().toISOString(),
206
- action: (options.add?.length ?? 0) > 0 ? 'accept' : 'remove',
207
- fingerprints: [...(options.add ?? []), ...(options.remove ?? [])],
208
- ...(options.reason?.trim() ? { reason: options.reason.trim() } : {}),
209
- };
210
- atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
211
- atomicWriteFile(metadataPath, `${JSON.stringify({
212
- ...(previous !== null && typeof previous === 'object' ? previous : {}),
213
- version: 1,
214
- generatedAt: new Date().toISOString(),
215
- findings: fingerprints.length,
216
- decisions: [...history, decision],
217
- }, null, 2)}\n`);
218
- 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
+ });
219
467
  }
220
- export function writeGuardConfig(root, guardConfig) {
221
- mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
222
- const { contracts = [], ...rulesConfig } = guardConfig;
223
- atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify(rulesConfig, null, 2)}\n`);
224
- 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);
225
470
  }
226
471
  export function writeGuardProposals(root, proposals) {
227
472
  writeGuardArtifact(root, GUARD_PROPOSALS_FILE, proposals);
228
473
  }
229
- export function recordGuardProposalDecision(root, decisions) {
230
- const proposals = loadGuardProposals(root);
474
+ function buildGuardProposalWithDecisionsUnlocked(root, decisions, options) {
475
+ const proposals = loadGuardProposalsUnlocked(root);
231
476
  if (!proposals || decisions.length === 0)
232
477
  return;
233
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
+ })();
234
489
  const nextDecisions = decisions.map((decision) => ({
235
490
  ...decision,
236
491
  decidedAt,
492
+ source: options.source,
493
+ ...(process.env.GUARD_APPROVER?.trim() ? { actor: process.env.GUARD_APPROVER.trim() } : {}),
494
+ ...(commit ? { commit } : {}),
237
495
  ...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
238
496
  ...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
239
497
  ...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
240
498
  }));
241
- writeGuardProposals(root, {
499
+ return {
242
500
  ...proposals,
243
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));
244
528
  });
245
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
+ }
246
542
  export function getPendingGuardProposals(proposals) {
247
543
  if (!proposals)
248
544
  return [];
@@ -315,6 +611,9 @@ export function buildGuardProposals(model, guardConfig) {
315
611
  const proposal = {
316
612
  version: 1,
317
613
  generatedAt: new Date().toISOString(),
614
+ ...(process.env.GUARD_PROPOSER?.trim()
615
+ ? { generatedBy: process.env.GUARD_PROPOSER.trim() }
616
+ : {}),
318
617
  rules: guardConfig.rules.filter((rule) => rule.status === 'proposed'),
319
618
  contracts: [
320
619
  ...(guardConfig.contracts ?? []).filter((contract) => contract.status === 'proposed'),
@@ -443,23 +742,37 @@ export function buildGeneratedGuardConfig(model) {
443
742
  return { ...defaultGuardConfig, rules: proposedRules };
444
743
  }
445
744
  export function writeGuardMemory(root, model) {
446
- writeGuardArtifact(root, GUARD_ARCHITECTURE_FILE, buildArchitectureMemory(model));
447
- 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
+ });
448
749
  }
449
750
  export function writeBaseline(root, findings, model) {
450
- mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
451
- const fingerprints = [...new Set(findings.map((finding) => finding.fingerprint))].sort();
452
- atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
453
- atomicWriteFile(resolve(root, GUARD_BASELINE_META_FILE), `${JSON.stringify({
454
- version: 1,
455
- generatedAt: new Date().toISOString(),
456
- findings: fingerprints.length,
457
- ...(model ? { projectFingerprint: fingerprintProjectModel(model) } : {}),
458
- guardSchemaVersion: 1,
459
- purpose: 'Initial Guard state; findings are suppressed unless they change fingerprint.',
460
- }, 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
+ });
461
764
  }
462
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
+ }
463
776
  const value = readJson(resolve(root, GUARD_PROJECT_FILE));
464
777
  if (value === null ||
465
778
  typeof value !== 'object' ||
@@ -469,8 +782,40 @@ export function loadProjectModel(root) {
469
782
  return value;
470
783
  }
471
784
  export function writeProjectModel(root, model) {
472
- mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
473
- 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);
474
819
  }
475
820
  function projectRevision(root) {
476
821
  try {
@@ -484,16 +829,18 @@ function projectRevision(root) {
484
829
  }
485
830
  }
486
831
  export function writeProjectSnapshot(root, model) {
487
- const baseRevision = projectRevision(root);
488
- const revision = model.git.dirty && baseRevision !== 'working-tree'
489
- ? `${baseRevision}-working-tree-${createHash('sha256')
490
- .update(JSON.stringify(model))
491
- .digest('hex')
492
- .slice(0, 12)}`
493
- : baseRevision;
494
- mkdirSync(resolve(root, GUARD_HISTORY_DIR), { recursive: true });
495
- atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
496
- 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
+ });
497
844
  }
498
845
  function isProjectPath(root, value) {
499
846
  const normalized = value.replace(/\\/g, '/');
@@ -519,6 +866,23 @@ function isProjectPath(root, value) {
519
866
  export function loadGuardArtifact(root, relativePath) {
520
867
  if (!isProjectPath(root, relativePath))
521
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
+ }
522
886
  return readJson(resolve(root, relativePath));
523
887
  }
524
888
  export function validateGuardContracts(root, contracts = []) {
@@ -595,6 +959,51 @@ export function validateGuardContracts(root, contracts = []) {
595
959
  }
596
960
  return issues;
597
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
+ }
598
1007
  function isSourceFile(file) {
599
1008
  return /\.(?:ts|tsx|js|jsx)$/.test(file) && !/\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/.test(file);
600
1009
  }
@@ -892,6 +1301,49 @@ function architectureInsightFindings(root, changed) {
892
1301
  }
893
1302
  return findings;
894
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
+ }
895
1347
  function isSafeReviewFile(file) {
896
1348
  return !(file === '.env' ||
897
1349
  file.startsWith('.env.') ||
@@ -1082,6 +1534,7 @@ export function scanGuard(root, guardConfig, options = {}) {
1082
1534
  const allFindings = [
1083
1535
  ...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
1084
1536
  ...scanContracts(root, guardConfig.contracts ?? [], scope),
1537
+ ...scanBudgets(root, model, guardConfig.budgets ?? [], scope),
1085
1538
  ...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
1086
1539
  ];
1087
1540
  const baseline = options.baseline ?? new Set();
@@ -1134,22 +1587,22 @@ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(),
1134
1587
  };
1135
1588
  }
1136
1589
  export function initializeGuard(root, options = {}) {
1137
- if (existsSync(resolve(root, GUARD_BASELINE_FILE)) && !options.force) {
1138
- throw new GuardAlreadyInitializedError();
1139
- }
1140
- const projectModel = discoverProject(root);
1141
- const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
1142
- ensureGeneratedStateIgnored(root, projectModel);
1143
- writeGuardConfig(root, guardConfig);
1144
- if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
1145
- writeGuardAgentConfig(root);
1146
- }
1147
- writeGuardMemory(root, projectModel);
1148
- writeProjectModel(root, projectModel);
1149
- writeProjectSnapshot(root, projectModel);
1150
- writeGuardProposals(root, buildGuardProposals(projectModel, guardConfig));
1151
- const initialReport = scanGuard(root, guardConfig, { includeArchitectureInsights: true });
1152
- writeBaseline(root, initialReport.findings, projectModel);
1153
- return { config: guardConfig, report: { ...initialReport, findings: [] } };
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);
1154
1607
  }
1155
1608
  export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };