@codapult/guard 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -0
  3. package/dist/adapters/agents/agent-integration.d.ts +9 -0
  4. package/dist/adapters/agents/agent-integration.js +62 -0
  5. package/dist/adapters/command.d.ts +21 -0
  6. package/dist/adapters/command.js +60 -0
  7. package/dist/adapters/project-checks.d.ts +37 -0
  8. package/dist/adapters/project-checks.js +149 -0
  9. package/dist/cli/commands/guard.d.ts +55 -0
  10. package/dist/cli/commands/guard.js +510 -0
  11. package/dist/cli/index.d.ts +2 -0
  12. package/dist/cli/index.js +75 -0
  13. package/dist/cli/ui.d.ts +6 -0
  14. package/dist/cli/ui.js +7 -0
  15. package/dist/commands/guard.d.ts +2 -0
  16. package/dist/commands/guard.js +2 -0
  17. package/dist/core/analysis/doctor.d.ts +12 -0
  18. package/dist/core/analysis/doctor.js +85 -0
  19. package/dist/core/analysis/packs.d.ts +21 -0
  20. package/dist/core/analysis/packs.js +251 -0
  21. package/dist/core/config.d.ts +6 -0
  22. package/dist/core/config.js +6 -0
  23. package/dist/core/discovery/discovery.d.ts +134 -0
  24. package/dist/core/discovery/discovery.js +816 -0
  25. package/dist/core/guard.d.ts +189 -0
  26. package/dist/core/guard.js +936 -0
  27. package/dist/core/history/history.d.ts +24 -0
  28. package/dist/core/history/history.js +65 -0
  29. package/dist/core/output/sarif.d.ts +35 -0
  30. package/dist/core/output/sarif.js +29 -0
  31. package/dist/core/policy/schemas.d.ts +272 -0
  32. package/dist/core/policy/schemas.js +72 -0
  33. package/dist/core/verification/verify.d.ts +29 -0
  34. package/dist/core/verification/verify.js +77 -0
  35. package/dist/index.d.ts +10 -0
  36. package/dist/index.js +9 -0
  37. package/dist/mcp/prompts.d.ts +2 -0
  38. package/dist/mcp/prompts.js +54 -0
  39. package/dist/mcp/resources.d.ts +2 -0
  40. package/dist/mcp/resources.js +64 -0
  41. package/dist/mcp/server.d.ts +1 -0
  42. package/dist/mcp/server.js +17 -0
  43. package/dist/mcp/tools/guard.d.ts +2 -0
  44. package/dist/mcp/tools/guard.js +375 -0
  45. package/package.json +117 -0
@@ -0,0 +1,936 @@
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';
5
+ import { Project, SyntaxKind } from 'ts-morph';
6
+ import { discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
7
+ import { config } from './config.js';
8
+ import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
9
+ import { guardAgentConfigSchema, guardConfigSchema, guardContractsFileSchema, guardProposalSchema, } from './policy/schemas.js';
10
+ export function classifyGuardOutcome(input) {
11
+ if (input.configured === false)
12
+ return 'not-configured';
13
+ if ((input.errors ?? 0) > 0)
14
+ return 'fail';
15
+ if (input.needsReview)
16
+ return 'needs-review';
17
+ return (input.warnings ?? 0) > 0 ? 'warning' : 'pass';
18
+ }
19
+ export const GUARD_DIR = `.${config.appName}/guard`;
20
+ export const GUARD_RULES_FILE = `${GUARD_DIR}/rules.json`;
21
+ export const GUARD_BASELINE_FILE = `${GUARD_DIR}/baseline.json`;
22
+ export const GUARD_BASELINE_META_FILE = `${GUARD_DIR}/baseline-meta.json`;
23
+ export const GUARD_PROJECT_FILE = `${GUARD_DIR}/project.json`;
24
+ export const GUARD_HISTORY_DIR = `${GUARD_DIR}/history`;
25
+ export const GUARD_ARCHITECTURE_FILE = `${GUARD_DIR}/architecture.json`;
26
+ export const GUARD_CONVENTIONS_FILE = `${GUARD_DIR}/conventions.json`;
27
+ export const GUARD_AGENT_FILE = `${GUARD_DIR}/agent.json`;
28
+ export const GUARD_CONTRACTS_FILE = `${GUARD_DIR}/contracts.json`;
29
+ export const GUARD_PROPOSALS_FILE = `${GUARD_DIR}/proposals.json`;
30
+ export const defaultGuardConfig = {
31
+ version: 1,
32
+ rules: [],
33
+ contracts: [],
34
+ };
35
+ export const defaultGuardAgentConfig = {
36
+ version: 1,
37
+ tools: 'auto',
38
+ tooling: {},
39
+ completionGate: {
40
+ enabled: true,
41
+ maxIterations: 3,
42
+ projectChecks: true,
43
+ checks: ['lint', 'typecheck', 'test', 'build'],
44
+ },
45
+ };
46
+ export class GuardAlreadyInitializedError extends Error {
47
+ constructor() {
48
+ super(`Guard is already initialized (${GUARD_BASELINE_FILE} exists).`);
49
+ this.name = 'GuardAlreadyInitializedError';
50
+ }
51
+ }
52
+ function isGuardConfig(value) {
53
+ return guardConfigSchema.safeParse(value).success;
54
+ }
55
+ function readJson(filePath) {
56
+ try {
57
+ return JSON.parse(readFileSync(filePath, 'utf8'));
58
+ }
59
+ catch {
60
+ return undefined;
61
+ }
62
+ }
63
+ function atomicWriteFile(path, content) {
64
+ const temporaryPath = `${path}.tmp-${process.pid}`;
65
+ writeFileSync(temporaryPath, content, 'utf8');
66
+ renameSync(temporaryPath, path);
67
+ }
68
+ function writeGuardArtifact(root, relativePath, value) {
69
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
70
+ atomicWriteFile(resolve(root, relativePath), `${JSON.stringify(value, null, 2)}\n`);
71
+ }
72
+ function hasProjectTool(model, name) {
73
+ 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)));
74
+ }
75
+ function ensureGeneratedStateIgnored(root, model) {
76
+ const ignoreFiles = [
77
+ ...(hasProjectTool(model, 'prettier') ? ['.prettierignore'] : []),
78
+ ...(hasProjectTool(model, 'biome') ? ['.biomeignore'] : []),
79
+ ...(hasProjectTool(model, 'oxfmt') && !hasProjectTool(model, 'prettier')
80
+ ? ['.prettierignore']
81
+ : []),
82
+ ];
83
+ const marker = `# ${config.projectName} Guard generated state`;
84
+ for (const ignoreFile of ignoreFiles) {
85
+ const path = resolve(root, ignoreFile);
86
+ const content = existsSync(path) ? readFileSync(path, 'utf8') : '';
87
+ if (content.includes(marker) ||
88
+ content.split(/\r?\n/).some((line) => line.trim() === `${GUARD_DIR}/`)) {
89
+ continue;
90
+ }
91
+ const prefix = content.length > 0 && !content.endsWith('\n') ? `${content}\n` : content;
92
+ atomicWriteFile(path, `${prefix}${marker}\n${GUARD_DIR}/\n`);
93
+ }
94
+ }
95
+ export function isGuardContractsFile(value) {
96
+ return guardContractsFileSchema.safeParse(value).success;
97
+ }
98
+ export function isGuardProposalFile(value) {
99
+ return guardProposalSchema.safeParse(value).success;
100
+ }
101
+ export function fingerprintProjectModel(model) {
102
+ return createHash('sha256').update(JSON.stringify(model)).digest('hex');
103
+ }
104
+ export function getGuardProposalFreshness(model, proposals) {
105
+ if (!proposals?.projectFingerprint)
106
+ return 'unknown';
107
+ return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
108
+ }
109
+ export function loadGuardConfig(root) {
110
+ const value = readJson(resolve(root, GUARD_RULES_FILE));
111
+ if (!isGuardConfig(value))
112
+ return undefined;
113
+ const contractsPath = resolve(root, GUARD_CONTRACTS_FILE);
114
+ if (existsSync(contractsPath)) {
115
+ const contractFile = readJson(contractsPath);
116
+ if (!isGuardContractsFile(contractFile))
117
+ return undefined;
118
+ return { ...value, contracts: contractFile.contracts };
119
+ }
120
+ return {
121
+ ...value,
122
+ contracts: value.contracts ?? [],
123
+ };
124
+ }
125
+ export function loadGuardProposals(root) {
126
+ const value = readJson(resolve(root, GUARD_PROPOSALS_FILE));
127
+ return isGuardProposalFile(value) ? value : undefined;
128
+ }
129
+ export function isGuardAgentConfig(value) {
130
+ return guardAgentConfigSchema.safeParse(value).success;
131
+ }
132
+ export function loadGuardAgentConfig(root) {
133
+ const value = readJson(resolve(root, GUARD_AGENT_FILE));
134
+ return isGuardAgentConfig(value) ? value : defaultGuardAgentConfig;
135
+ }
136
+ export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
137
+ writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
138
+ }
139
+ export function loadGuardArtifact(root, relativePath) {
140
+ return readJson(resolve(root, relativePath));
141
+ }
142
+ export function loadBaseline(root) {
143
+ const value = readJson(resolve(root, GUARD_BASELINE_FILE));
144
+ if (!Array.isArray(value))
145
+ return new Set();
146
+ return new Set(value.filter((item) => typeof item === 'string'));
147
+ }
148
+ export function updateBaseline(root, options = {}) {
149
+ const next = loadBaseline(root);
150
+ for (const fingerprint of options.add ?? [])
151
+ next.add(fingerprint);
152
+ for (const fingerprint of options.remove ?? [])
153
+ next.delete(fingerprint);
154
+ const fingerprints = [...next].sort();
155
+ const metadataPath = resolve(root, GUARD_BASELINE_META_FILE);
156
+ const previous = readJson(metadataPath);
157
+ const history = previous !== null &&
158
+ typeof previous === 'object' &&
159
+ Array.isArray(previous.decisions)
160
+ ? previous.decisions
161
+ : [];
162
+ const decision = {
163
+ at: new Date().toISOString(),
164
+ action: (options.add?.length ?? 0) > 0 ? 'accept' : 'remove',
165
+ fingerprints: [...(options.add ?? []), ...(options.remove ?? [])],
166
+ ...(options.reason?.trim() ? { reason: options.reason.trim() } : {}),
167
+ };
168
+ atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
169
+ atomicWriteFile(metadataPath, `${JSON.stringify({
170
+ ...(previous !== null && typeof previous === 'object' ? previous : {}),
171
+ version: 1,
172
+ generatedAt: new Date().toISOString(),
173
+ findings: fingerprints.length,
174
+ decisions: [...history, decision],
175
+ }, null, 2)}\n`);
176
+ return next;
177
+ }
178
+ export function writeGuardConfig(root, guardConfig) {
179
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
180
+ const { contracts = [], ...rulesConfig } = guardConfig;
181
+ atomicWriteFile(resolve(root, GUARD_RULES_FILE), `${JSON.stringify(rulesConfig, null, 2)}\n`);
182
+ atomicWriteFile(resolve(root, GUARD_CONTRACTS_FILE), `${JSON.stringify({ version: 1, contracts }, null, 2)}\n`);
183
+ }
184
+ export function writeGuardProposals(root, proposals) {
185
+ writeGuardArtifact(root, GUARD_PROPOSALS_FILE, proposals);
186
+ }
187
+ export function recordGuardProposalDecision(root, decisions) {
188
+ const proposals = loadGuardProposals(root);
189
+ if (!proposals || decisions.length === 0)
190
+ return;
191
+ const decidedAt = new Date().toISOString();
192
+ const nextDecisions = decisions.map((decision) => ({
193
+ ...decision,
194
+ decidedAt,
195
+ ...(proposals.proposalId ? { proposalId: proposals.proposalId } : {}),
196
+ ...(proposals.contentFingerprint ? { proposalFingerprint: proposals.contentFingerprint } : {}),
197
+ ...(typeof proposals.revision === 'number' ? { revision: proposals.revision } : {}),
198
+ }));
199
+ writeGuardProposals(root, {
200
+ ...proposals,
201
+ decisions: [...(proposals.decisions ?? []), ...nextDecisions],
202
+ });
203
+ }
204
+ export function buildGuardProposals(model, guardConfig) {
205
+ const capabilities = Object.keys(model.capabilities);
206
+ const questions = [];
207
+ const observedContracts = [];
208
+ if (capabilities.includes('identity')) {
209
+ observedContracts.push({
210
+ id: 'canonical-auth-entrypoint',
211
+ statement: 'Protected server operations must use the project authentication entrypoint before accessing protected data.',
212
+ guidance: [
213
+ 'Use the observed authentication service instead of reading session storage directly.',
214
+ ],
215
+ references: model.modules
216
+ .map((module) => module.path)
217
+ .filter((path) => /(?:^|\/)(?:auth|authentication|identity|session)(?:\/|\.|$)/i.test(path))
218
+ .slice(0, 5),
219
+ status: 'proposed',
220
+ confidence: 'medium',
221
+ evidence: model.capabilities.identity.evidence,
222
+ });
223
+ }
224
+ if (capabilities.includes('persistence')) {
225
+ observedContracts.push({
226
+ id: 'persistence-boundary',
227
+ statement: 'Application-facing code should use the observed persistence boundary instead of scattering database access.',
228
+ guidance: ['Prefer the observed repository or service modules for database access.'],
229
+ references: model.files
230
+ .filter((file) => file.kind === 'schema' ||
231
+ /(?:^|\/)(?:db|database|repositories)(?:\/|$)/i.test(file.path))
232
+ .map((file) => file.path)
233
+ .slice(0, 5),
234
+ status: 'proposed',
235
+ confidence: 'low',
236
+ evidence: model.capabilities.persistence.evidence,
237
+ });
238
+ }
239
+ if (capabilities.includes('identity')) {
240
+ questions.push('Which authentication and organization-authorization entrypoints are canonical?');
241
+ }
242
+ if (capabilities.includes('payments')) {
243
+ questions.push('Which billing adapter owns checkout, webhook, and idempotency behavior?');
244
+ }
245
+ if (capabilities.includes('persistence')) {
246
+ questions.push('Which modules are allowed to access the database directly?');
247
+ }
248
+ const proposal = {
249
+ version: 1,
250
+ generatedAt: new Date().toISOString(),
251
+ rules: guardConfig.rules.filter((rule) => rule.status === 'proposed'),
252
+ contracts: [
253
+ ...(guardConfig.contracts ?? []).filter((contract) => contract.status === 'proposed'),
254
+ ...observedContracts.filter((observedContract) => !(guardConfig.contracts ?? []).some((contract) => contract.id === observedContract.id)),
255
+ ],
256
+ questions,
257
+ };
258
+ const projectFingerprint = fingerprintProjectModel(model);
259
+ const contentFingerprint = createHash('sha256')
260
+ .update(JSON.stringify({ rules: proposal.rules, contracts: proposal.contracts, questions }))
261
+ .digest('hex');
262
+ return {
263
+ ...proposal,
264
+ projectFingerprint,
265
+ revision: 1,
266
+ contentFingerprint,
267
+ proposalId: createHash('sha256')
268
+ .update(JSON.stringify({ projectFingerprint, ...proposal, generatedAt: undefined }))
269
+ .digest('hex')
270
+ .slice(0, 16),
271
+ };
272
+ }
273
+ export function buildArchitectureMemory(model) {
274
+ const persistencePackages = Object.keys({
275
+ ...model.project.dependencies,
276
+ ...model.project.devDependencies,
277
+ }).filter((name) => [
278
+ 'drizzle-orm',
279
+ 'prisma',
280
+ '@prisma/client',
281
+ 'typeorm',
282
+ 'sequelize',
283
+ 'mongoose',
284
+ 'knex',
285
+ ].includes(name));
286
+ return {
287
+ version: 1,
288
+ generatedBy: 'codapult-guard init',
289
+ frameworks: model.project.frameworks,
290
+ workspacePackages: model.project.workspacePackages,
291
+ capabilities: model.capabilities,
292
+ map: {
293
+ layers: model.insights.layers,
294
+ edges: model.insights.layerEdges,
295
+ },
296
+ dependencyGraph: {
297
+ edges: model.insights.dependencyEdges,
298
+ },
299
+ layers: model.insights.layers,
300
+ boundaries: model.insights.boundaries,
301
+ persistence: {
302
+ packages: persistencePackages,
303
+ schemaFiles: model.schemas,
304
+ },
305
+ authorization: {
306
+ implementationModules: model.modules
307
+ .filter((module) => /(?:^|\/)(?:auth|authentication)(?:\/|\.|$)/i.test(module.path))
308
+ .map((module) => module.path),
309
+ },
310
+ billing: {
311
+ providerCandidates: Object.keys({
312
+ ...model.project.dependencies,
313
+ ...model.project.devDependencies,
314
+ }).filter((name) => /stripe|lemonsqueezy|paddle|paypal|braintree/i.test(name)),
315
+ },
316
+ routes: model.patterns.routeDetails,
317
+ environment: {
318
+ references: model.insights.envReferences,
319
+ },
320
+ cycles: model.insights.cycles,
321
+ packs: detectGuardPacks(model).map((pack) => ({
322
+ id: pack.id,
323
+ title: pack.title,
324
+ focus: pack.focus,
325
+ })),
326
+ packAssessments: assessGuardPacks(model),
327
+ };
328
+ }
329
+ export function buildConventionsMemory(model) {
330
+ const directives = [...new Set(model.modules.flatMap((module) => module.directives))].sort();
331
+ const sourceExtensions = [
332
+ ...new Set(model.files.filter((file) => file.kind === 'source').map((file) => extname(file.path))),
333
+ ].sort();
334
+ return {
335
+ version: 1,
336
+ generatedBy: 'codapult-guard init',
337
+ packageManager: model.project.packageManager,
338
+ scripts: model.tests.scripts,
339
+ configFiles: model.configs,
340
+ testFiles: model.tests.files,
341
+ sourceExtensions,
342
+ directives,
343
+ routeHandlers: model.patterns.routeHandlers,
344
+ barrelFiles: model.patterns.barrelFiles,
345
+ };
346
+ }
347
+ export function buildGeneratedGuardConfig(model) {
348
+ const isPersistenceImport = (importPath) => /(?:^|\/)(?:database|db)(?:\/|$)/i.test(importPath) ||
349
+ /^(?:@prisma\codapult-guardent|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
350
+ const persistenceImports = [
351
+ ...new Set(model.modules.flatMap((module) => module.imports).filter(isPersistenceImport)),
352
+ ].sort();
353
+ const clientPersistenceFiles = model.insights.boundaries
354
+ .find((boundary) => boundary.kind === 'client')
355
+ ?.files.filter((file) => model.modules.some((module) => module.path === file &&
356
+ module.imports.some((importPath) => persistenceImports.includes(importPath)))) ?? [];
357
+ const proposedRules = clientPersistenceFiles.length > 0
358
+ ? [
359
+ {
360
+ id: 'client-no-persistence-import',
361
+ description: 'Client modules should not import persistence-layer modules.',
362
+ severity: 'error',
363
+ kind: 'client-forbidden-import',
364
+ patterns: persistenceImports,
365
+ status: 'proposed',
366
+ confidence: 'medium',
367
+ evidence: clientPersistenceFiles,
368
+ },
369
+ ]
370
+ : [];
371
+ return { ...defaultGuardConfig, rules: proposedRules };
372
+ }
373
+ export function writeGuardMemory(root, model) {
374
+ writeGuardArtifact(root, GUARD_ARCHITECTURE_FILE, buildArchitectureMemory(model));
375
+ writeGuardArtifact(root, GUARD_CONVENTIONS_FILE, buildConventionsMemory(model));
376
+ }
377
+ export function writeBaseline(root, findings, model) {
378
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
379
+ const fingerprints = [...new Set(findings.map((finding) => finding.fingerprint))].sort();
380
+ atomicWriteFile(resolve(root, GUARD_BASELINE_FILE), `${JSON.stringify(fingerprints, null, 2)}\n`);
381
+ atomicWriteFile(resolve(root, GUARD_BASELINE_META_FILE), `${JSON.stringify({
382
+ version: 1,
383
+ generatedAt: new Date().toISOString(),
384
+ findings: fingerprints.length,
385
+ ...(model ? { projectFingerprint: fingerprintProjectModel(model) } : {}),
386
+ guardSchemaVersion: 1,
387
+ purpose: 'Initial Guard state; findings are suppressed unless they change fingerprint.',
388
+ }, null, 2)}\n`);
389
+ }
390
+ export function loadProjectModel(root) {
391
+ const value = readJson(resolve(root, GUARD_PROJECT_FILE));
392
+ if (value === null ||
393
+ typeof value !== 'object' ||
394
+ value.version !== 1) {
395
+ return undefined;
396
+ }
397
+ return value;
398
+ }
399
+ export function writeProjectModel(root, model) {
400
+ mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
401
+ atomicWriteFile(resolve(root, GUARD_PROJECT_FILE), `${JSON.stringify(model, null, 2)}\n`);
402
+ }
403
+ function projectRevision(root) {
404
+ try {
405
+ const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, stdio: 'pipe' })
406
+ .toString()
407
+ .trim();
408
+ return revision || 'working-tree';
409
+ }
410
+ catch {
411
+ return 'working-tree';
412
+ }
413
+ }
414
+ export function writeProjectSnapshot(root, model) {
415
+ const baseRevision = projectRevision(root);
416
+ const revision = model.git.dirty && baseRevision !== 'working-tree'
417
+ ? `${baseRevision}-working-tree-${createHash('sha256')
418
+ .update(JSON.stringify(model))
419
+ .digest('hex')
420
+ .slice(0, 12)}`
421
+ : baseRevision;
422
+ mkdirSync(resolve(root, GUARD_HISTORY_DIR), { recursive: true });
423
+ atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
424
+ return revision;
425
+ }
426
+ export function validateGuardContracts(root, contracts = []) {
427
+ const issues = [];
428
+ for (const contract of contracts) {
429
+ if (contract.kind === 'import-boundary' &&
430
+ (contract.mustImport?.length ?? 0) === 0 &&
431
+ (contract.mustNotImport?.length ?? 0) === 0) {
432
+ issues.push({
433
+ contractId: contract.id,
434
+ field: 'definition',
435
+ value: contract.kind,
436
+ message: 'Import-boundary contracts need mustImport or mustNotImport patterns.',
437
+ });
438
+ }
439
+ if (contract.kind === 'required-call' && (contract.mustCall?.length ?? 0) === 0) {
440
+ issues.push({
441
+ contractId: contract.id,
442
+ field: 'definition',
443
+ value: contract.kind,
444
+ message: 'Required-call contracts need at least one mustCall pattern.',
445
+ });
446
+ }
447
+ for (const scope of contract.scope ?? []) {
448
+ if (!existsSync(resolve(root, scope))) {
449
+ issues.push({
450
+ contractId: contract.id,
451
+ field: 'scope',
452
+ value: scope,
453
+ message: `Contract scope does not exist: ${scope}`,
454
+ });
455
+ }
456
+ }
457
+ for (const entrypoint of contract.entrypoints ?? []) {
458
+ if (!existsSync(resolve(root, entrypoint))) {
459
+ issues.push({
460
+ contractId: contract.id,
461
+ field: 'scope',
462
+ value: entrypoint,
463
+ message: `Contract entrypoint does not exist: ${entrypoint}`,
464
+ });
465
+ }
466
+ }
467
+ for (const excluded of contract.exclude ?? []) {
468
+ if (!existsSync(resolve(root, excluded))) {
469
+ issues.push({
470
+ contractId: contract.id,
471
+ field: 'scope',
472
+ value: excluded,
473
+ message: `Contract exclusion does not exist: ${excluded}`,
474
+ });
475
+ }
476
+ }
477
+ for (const reference of contract.references ?? []) {
478
+ if (!existsSync(resolve(root, reference))) {
479
+ issues.push({
480
+ contractId: contract.id,
481
+ field: 'reference',
482
+ value: reference,
483
+ message: `Contract reference does not exist: ${reference}`,
484
+ });
485
+ }
486
+ }
487
+ }
488
+ return issues;
489
+ }
490
+ function isSourceFile(file) {
491
+ return /\.(?:ts|tsx|js|jsx)$/.test(file) && !/\.(?:test|spec)\.(?:ts|tsx|js|jsx)$/.test(file);
492
+ }
493
+ function listSourceFiles(root, directory = root) {
494
+ const ignored = new Set(['.git', '.next', 'dist', 'node_modules', GUARD_DIR.split('/')[0]]);
495
+ const files = [];
496
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
497
+ if (ignored.has(entry.name))
498
+ continue;
499
+ const fullPath = resolve(directory, entry.name);
500
+ if (entry.isDirectory())
501
+ files.push(...listSourceFiles(root, fullPath));
502
+ else if (entry.isFile() && isSourceFile(entry.name))
503
+ files.push(relative(root, fullPath));
504
+ }
505
+ return files.sort();
506
+ }
507
+ function patternMatches(importPath, pattern) {
508
+ return pattern.endsWith('*')
509
+ ? importPath.startsWith(pattern.slice(0, -1))
510
+ : importPath === pattern || importPath.startsWith(`${pattern}/`);
511
+ }
512
+ function ruleAppliesToFile(rule, file) {
513
+ return (!rule.files || rule.files.some((prefix) => file === prefix || file.startsWith(`${prefix}/`)));
514
+ }
515
+ function contractAppliesToFile(contract, file) {
516
+ const matches = (patterns) => patterns.some((pattern) => pattern.endsWith('*')
517
+ ? file.startsWith(pattern.slice(0, -1))
518
+ : file === pattern || file.startsWith(`${pattern}/`));
519
+ const includedByScope = !contract.scope || matches(contract.scope);
520
+ const includedByEntrypoint = !contract.entrypoints || matches(contract.entrypoints);
521
+ const excluded = contract.exclude !== undefined && matches(contract.exclude);
522
+ return includedByScope && includedByEntrypoint && !excluded;
523
+ }
524
+ function importLine(sourceFile, importPath) {
525
+ const declaration = sourceFile
526
+ .getImportDeclarations()
527
+ .find((item) => item.getModuleSpecifierValue() === importPath);
528
+ if (declaration)
529
+ return declaration.getStartLineNumber();
530
+ const exportDeclaration = sourceFile
531
+ .getExportDeclarations()
532
+ .find((item) => item.getModuleSpecifierValue() === importPath);
533
+ if (exportDeclaration)
534
+ return exportDeclaration.getStartLineNumber();
535
+ const dynamicImport = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).find((item) => {
536
+ if (item.getExpression().getText() !== 'import')
537
+ return false;
538
+ const argument = item.getArguments()[0];
539
+ return argument.isKind(SyntaxKind.StringLiteral) && argument.getLiteralValue() === importPath;
540
+ });
541
+ return dynamicImport?.getStartLineNumber() ?? 1;
542
+ }
543
+ function scanFile(root, file, rules, module, astProject) {
544
+ if (!module)
545
+ return [];
546
+ const isClient = module.directives.includes('use client');
547
+ let sourceFile = astProject.getSourceFile(resolve(root, file));
548
+ if (!sourceFile) {
549
+ try {
550
+ sourceFile = astProject.addSourceFileAtPath(resolve(root, file));
551
+ }
552
+ catch {
553
+ sourceFile = undefined;
554
+ }
555
+ }
556
+ const findings = [];
557
+ const importPaths = [
558
+ ...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
559
+ ];
560
+ for (const importPath of importPaths) {
561
+ const line = sourceFile ? importLine(sourceFile, importPath) : 1;
562
+ for (const rule of rules) {
563
+ if (rule.status === 'proposed' || !ruleAppliesToFile(rule, file))
564
+ continue;
565
+ if (rule.kind === 'client-forbidden-import' && !isClient)
566
+ continue;
567
+ if (!rule.patterns.some((pattern) => patternMatches(importPath, pattern)))
568
+ continue;
569
+ findings.push({
570
+ ruleId: rule.id,
571
+ severity: rule.severity,
572
+ file,
573
+ line,
574
+ importPath,
575
+ message: rule.description,
576
+ fingerprint: `${rule.id}|${file}|${importPath}`,
577
+ });
578
+ }
579
+ }
580
+ return findings;
581
+ }
582
+ function scanContracts(root, contracts, changed) {
583
+ const model = discoverProject(root);
584
+ const findings = [];
585
+ for (const contract of contracts) {
586
+ if (contract.status === 'proposed' || !contract.kind || contract.kind === 'guidance')
587
+ continue;
588
+ const severity = contract.severity ?? 'error';
589
+ for (const module of model.modules.filter((item) => (!changed || changed.has(item.path)) && contractAppliesToFile(contract, item.path))) {
590
+ if (contract.kind === 'import-boundary') {
591
+ for (const importPath of module.imports) {
592
+ if (contract.mustNotImport?.some((pattern) => patternMatches(importPath, pattern))) {
593
+ findings.push({
594
+ ruleId: `contract:${contract.id}`,
595
+ severity,
596
+ file: module.path,
597
+ line: 1,
598
+ importPath,
599
+ message: contract.statement,
600
+ fingerprint: `contract-import|${contract.id}|${module.path}|${importPath}`,
601
+ });
602
+ }
603
+ }
604
+ if (contract.mustImport !== undefined &&
605
+ contract.mustImport.length > 0 &&
606
+ !module.imports.some((importPath) => contract.mustImport?.some((pattern) => patternMatches(importPath, pattern)))) {
607
+ findings.push({
608
+ ruleId: `contract:${contract.id}`,
609
+ severity,
610
+ file: module.path,
611
+ line: 1,
612
+ importPath: 'contract',
613
+ message: contract.statement,
614
+ fingerprint: `contract-required-import|${contract.id}|${module.path}`,
615
+ });
616
+ }
617
+ }
618
+ if (contract.kind === 'required-call' &&
619
+ contract.mustCall !== undefined &&
620
+ contract.mustCall.length > 0 &&
621
+ !module.calls.some((call) => contract.mustCall?.some((required) => call === required || call.endsWith(`.${required}`)))) {
622
+ findings.push({
623
+ ruleId: `contract:${contract.id}`,
624
+ severity,
625
+ file: module.path,
626
+ line: 1,
627
+ importPath: 'contract',
628
+ message: contract.statement,
629
+ fingerprint: `contract-required-call|${contract.id}|${module.path}`,
630
+ });
631
+ }
632
+ }
633
+ }
634
+ return findings;
635
+ }
636
+ function changedFiles(root) {
637
+ try {
638
+ const changed = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
639
+ cwd: root,
640
+ stdio: 'pipe',
641
+ })
642
+ .toString()
643
+ .trim();
644
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
645
+ cwd: root,
646
+ stdio: 'pipe',
647
+ })
648
+ .toString()
649
+ .trim();
650
+ return new Set([
651
+ ...(changed ? changed.split('\n') : []),
652
+ ...(untracked ? untracked.split('\n') : []),
653
+ ]);
654
+ }
655
+ catch {
656
+ return undefined;
657
+ }
658
+ }
659
+ function architectureInsightFindings(root, changed) {
660
+ const model = discoverProject(root);
661
+ const findings = model.insights.cycles
662
+ .filter((cycle) => !changed || cycle.some((file) => changed.has(file)))
663
+ .map((cycle) => ({
664
+ ruleId: 'architecture-cycle',
665
+ severity: 'warning',
666
+ file: cycle[0] ?? 'project',
667
+ line: 1,
668
+ importPath: 'architecture',
669
+ message: `Circular dependency detected: ${cycle.join(' -> ')}`,
670
+ fingerprint: `architecture-cycle|${cycle.join('|')}`,
671
+ }));
672
+ const clientFiles = new Set(model.insights.boundaries
673
+ .filter((boundary) => boundary.kind === 'client')
674
+ .flatMap((boundary) => boundary.files));
675
+ const serverOnlyImports = /^(?:server-only|next\/(?:headers|cookies|server|cache)|node:fs|fs|node:child_process|child_process)(?:\/|$)/;
676
+ for (const module of model.modules) {
677
+ if (!clientFiles.has(module.path))
678
+ continue;
679
+ for (const importPath of module.imports.filter((value) => serverOnlyImports.test(value))) {
680
+ if (changed && !changed.has(module.path))
681
+ continue;
682
+ findings.push({
683
+ ruleId: 'client-server-boundary',
684
+ severity: 'error',
685
+ file: module.path,
686
+ line: 1,
687
+ importPath,
688
+ message: 'Client modules must not import server-only modules.',
689
+ fingerprint: `client-server-boundary|${module.path}|${importPath}`,
690
+ });
691
+ }
692
+ }
693
+ for (const reference of model.insights.envReferences) {
694
+ if (reference.declared)
695
+ continue;
696
+ const file = reference.files.find((value) => !changed || changed.has(value));
697
+ if (!file)
698
+ continue;
699
+ findings.push({
700
+ ruleId: 'undeclared-environment-reference',
701
+ severity: 'warning',
702
+ file,
703
+ line: 1,
704
+ importPath: `env:${reference.name}`,
705
+ message: `Environment variable ${reference.name} is not declared in .env.example.`,
706
+ fingerprint: `undeclared-environment-reference|${reference.name}|${file}`,
707
+ });
708
+ }
709
+ return findings;
710
+ }
711
+ function isSafeReviewFile(file) {
712
+ return !(file === '.env' ||
713
+ file.startsWith('.env.') ||
714
+ /(?:credentials|secrets?)/i.test(file) ||
715
+ /\.(?:pem|key|p12|pfx)$/i.test(file));
716
+ }
717
+ function isSafeReviewPath(root, file) {
718
+ try {
719
+ const projectRoot = `${realpathSync(root)}${sep}`;
720
+ const target = realpathSync(resolve(root, file));
721
+ return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
722
+ }
723
+ catch {
724
+ return false;
725
+ }
726
+ }
727
+ /** @internal Redacts common credential shapes before a diff enters an AI review packet. */
728
+ export function redactSensitiveText(value) {
729
+ let redacted = false;
730
+ let redactionCount = 0;
731
+ let redactedValue = value;
732
+ const replace = (pattern, replacement) => {
733
+ const next = redactedValue.replace(pattern, replacement);
734
+ if (next !== redactedValue) {
735
+ redacted = true;
736
+ redactionCount += (redactedValue.match(pattern) ?? []).length;
737
+ }
738
+ redactedValue = next;
739
+ };
740
+ replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]');
741
+ replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]');
742
+ replace(/((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\s*[:=]\s*["']?)[^\s"'`,}]+/gi, '$1[REDACTED]');
743
+ return { value: redactedValue, redacted, redactionCount };
744
+ }
745
+ function isSafeGitRevision(value) {
746
+ return (value.length > 0 && value.length <= 256 && !value.startsWith('-') && /^[\w./@-]+$/.test(value));
747
+ }
748
+ function reviewDiff(root, maxChars, base) {
749
+ try {
750
+ if (base !== undefined && !isSafeGitRevision(base)) {
751
+ return {
752
+ diff: '',
753
+ truncated: false,
754
+ redacted: false,
755
+ changedFiles: [],
756
+ error: `Unsafe Git base ref rejected: ${base}`,
757
+ };
758
+ }
759
+ const diffArgs = base
760
+ ? ['diff', '--name-only', `${base}...HEAD`]
761
+ : ['diff', '--name-only', 'HEAD'];
762
+ const contentDiffArgs = base
763
+ ? ['diff', '--no-ext-diff', '--unified=80', `${base}...HEAD`]
764
+ : ['diff', '--no-ext-diff', '--unified=80', 'HEAD'];
765
+ const trackedFiles = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
766
+ cwd: root,
767
+ stdio: 'pipe',
768
+ })
769
+ .toString()
770
+ .trim()
771
+ .split('\n')
772
+ .filter((file) => file.length > 0 && isSafeReviewFile(file) && isSafeReviewPath(root, file));
773
+ const trackedFilesForBase = execFileSync('git', diffArgs, {
774
+ cwd: root,
775
+ stdio: 'pipe',
776
+ })
777
+ .toString()
778
+ .trim()
779
+ .split('\n')
780
+ .filter((file) => file.length > 0 && isSafeReviewFile(file) && isSafeReviewPath(root, file));
781
+ const diffParts = [];
782
+ if (trackedFilesForBase.length > 0) {
783
+ diffParts.push(execFileSync('git', [...contentDiffArgs, '--', ...trackedFilesForBase], {
784
+ cwd: root,
785
+ stdio: 'pipe',
786
+ maxBuffer: Math.max(maxChars * 2, 1_000_000),
787
+ }).toString());
788
+ }
789
+ if (base && trackedFiles.length > 0) {
790
+ diffParts.push(execFileSync('git', ['diff', '--no-ext-diff', '--unified=80', 'HEAD', '--', ...trackedFiles], {
791
+ cwd: root,
792
+ stdio: 'pipe',
793
+ maxBuffer: Math.max(maxChars * 2, 1_000_000),
794
+ }).toString());
795
+ }
796
+ const rawDiff = diffParts.join('\n');
797
+ const redactedDiff = redactSensitiveText(rawDiff);
798
+ const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], {
799
+ cwd: root,
800
+ stdio: 'pipe',
801
+ })
802
+ .toString()
803
+ .trim()
804
+ .split('\n')
805
+ .filter((file) => file.length > 0 && isSafeReviewFile(file) && isSafeReviewPath(root, file));
806
+ const untrackedContent = untracked
807
+ .map((file) => {
808
+ try {
809
+ const content = readFileSync(resolve(root, file), 'utf8');
810
+ const redactedContent = redactSensitiveText(content);
811
+ return `diff --git a/${file} b/${file}\nnew file\n--- /dev/null\n+++ b/${file}\n${redactedContent.value
812
+ .split('\n')
813
+ .map((line) => `+${line}`)
814
+ .join('\n')}`;
815
+ }
816
+ catch {
817
+ return '';
818
+ }
819
+ })
820
+ .filter((content) => content.length > 0)
821
+ .join('\n');
822
+ const fullDiff = [redactedDiff.value, untrackedContent]
823
+ .filter((part) => part.length > 0)
824
+ .join('\n');
825
+ const redactedUntracked = untracked.some((file) => {
826
+ try {
827
+ return redactSensitiveText(readFileSync(resolve(root, file), 'utf8')).redacted;
828
+ }
829
+ catch {
830
+ return false;
831
+ }
832
+ });
833
+ return {
834
+ diff: fullDiff.slice(0, maxChars),
835
+ truncated: fullDiff.length > maxChars,
836
+ redacted: redactedDiff.redacted || redactedUntracked,
837
+ changedFiles: [...new Set([...trackedFilesForBase, ...trackedFiles, ...untracked])].sort(),
838
+ };
839
+ }
840
+ catch (error) {
841
+ const detail = error instanceof Error ? ` (${error.message})` : '';
842
+ return {
843
+ diff: '',
844
+ truncated: false,
845
+ redacted: false,
846
+ changedFiles: [],
847
+ error: base
848
+ ? `Unable to resolve or read Git base ref: ${base}${detail}`
849
+ : `Unable to read Git diff.${detail}`,
850
+ };
851
+ }
852
+ }
853
+ 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
+ const model = discoverProject(root);
857
+ const modules = new Map(model.modules.map((module) => [module.path, module]));
858
+ const astProject = (() => {
859
+ try {
860
+ return new Project({ tsConfigFilePath: resolve(root, 'tsconfig.json') });
861
+ }
862
+ catch {
863
+ return new Project({ skipAddingFilesFromTsConfig: true });
864
+ }
865
+ })();
866
+ const allFindings = [
867
+ ...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
868
+ ...scanContracts(root, guardConfig.contracts ?? [], changed),
869
+ ...(options.includeArchitectureInsights ? architectureInsightFindings(root, changed) : []),
870
+ ];
871
+ const baseline = options.baseline ?? new Set();
872
+ const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
873
+ return {
874
+ configured: true,
875
+ findings,
876
+ suppressed: allFindings.length - findings.length,
877
+ scannedFiles: files.length,
878
+ };
879
+ }
880
+ export function buildGuardReviewPacket(root, guardConfig, baseline = new Set(), maxDiffChars = 120_000, changedOnly = true, requirement, base) {
881
+ const diff = reviewDiff(root, maxDiffChars, base);
882
+ const project = discoverProject(root);
883
+ const report = scanGuard(root, guardConfig, {
884
+ changedOnly,
885
+ changedFiles: changedOnly ? new Set(diff.changedFiles) : undefined,
886
+ baseline,
887
+ includeArchitectureInsights: true,
888
+ });
889
+ return {
890
+ version: 1,
891
+ outcome: classifyGuardOutcome({ errors: diff.error ? 1 : 0, needsReview: !diff.error }),
892
+ ...(base ? { diffBase: base } : {}),
893
+ changedFiles: diff.changedFiles,
894
+ diff: diff.diff,
895
+ truncated: diff.truncated,
896
+ redacted: diff.redacted,
897
+ ...(diff.error ? { diffError: diff.error } : {}),
898
+ project,
899
+ contracts: guardConfig.contracts ?? [],
900
+ ...(requirement ? { requirement } : {}),
901
+ deterministicFindings: report.findings,
902
+ reviewInstructions: [
903
+ ...(requirement
904
+ ? [
905
+ 'Evaluate whether the changed work satisfies the supplied requirement; cite concrete diff evidence and identify uncovered acceptance criteria.',
906
+ ]
907
+ : [
908
+ 'No explicit requirement was supplied; infer review scope only from the diff and project context.',
909
+ ]),
910
+ 'Review the diff against the project model and contracts.',
911
+ 'Treat contracts as project-specific architectural constraints, not generic style rules.',
912
+ 'Report only evidence-backed concerns and distinguish violations from recommendations.',
913
+ 'Do not repeat findings already reported by deterministic tooling unless the diff changes their impact.',
914
+ ],
915
+ };
916
+ }
917
+ export function initializeGuard(root, options = {}) {
918
+ if (existsSync(resolve(root, GUARD_BASELINE_FILE)) && !options.force) {
919
+ throw new GuardAlreadyInitializedError();
920
+ }
921
+ const projectModel = discoverProject(root);
922
+ const guardConfig = loadGuardConfig(root) ?? buildGeneratedGuardConfig(projectModel);
923
+ ensureGeneratedStateIgnored(root, projectModel);
924
+ writeGuardConfig(root, guardConfig);
925
+ if (options.force || !existsSync(resolve(root, GUARD_AGENT_FILE))) {
926
+ writeGuardAgentConfig(root);
927
+ }
928
+ writeGuardMemory(root, projectModel);
929
+ writeProjectModel(root, projectModel);
930
+ writeProjectSnapshot(root, projectModel);
931
+ writeGuardProposals(root, buildGuardProposals(projectModel, guardConfig));
932
+ const initialReport = scanGuard(root, guardConfig, { includeArchitectureInsights: true });
933
+ writeBaseline(root, initialReport.findings, projectModel);
934
+ return { config: guardConfig, report: { ...initialReport, findings: [] } };
935
+ }
936
+ export { discoverProject, discoverProjectWithMetrics, findGuardRoot };