@codapult/guard 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
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 { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { dirname, extname, relative, resolve, sep } from 'node:path';
5
5
  import { Project, SyntaxKind } from 'ts-morph';
6
- import { discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
6
+ import { buildModuleTargetGraph } from './discovery/discovery.js';
7
+ import { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot, } from './discovery/discovery.js';
7
8
  import { config } from './config.js';
8
9
  import { assessGuardPacks, detectGuardPacks } from './analysis/packs.js';
9
10
  import { analyzeProjectImpact } from './analysis/impact.js';
@@ -50,6 +51,18 @@ export class GuardAlreadyInitializedError extends Error {
50
51
  this.name = 'GuardAlreadyInitializedError';
51
52
  }
52
53
  }
54
+ export class GuardConfigError extends Error {
55
+ constructor(path) {
56
+ super(`Guard configuration is invalid or unsupported: ${path}`);
57
+ this.name = 'GuardConfigError';
58
+ }
59
+ }
60
+ export class GuardStateBusyError extends Error {
61
+ constructor(path) {
62
+ super(`Guard state is being updated by another process: ${path}`);
63
+ this.name = 'GuardStateBusyError';
64
+ }
65
+ }
53
66
  function isGuardConfig(value) {
54
67
  return guardConfigSchema.safeParse(value).success;
55
68
  }
@@ -62,9 +75,37 @@ function readJson(filePath) {
62
75
  }
63
76
  }
64
77
  function atomicWriteFile(path, content) {
65
- const temporaryPath = `${path}.tmp-${process.pid}`;
66
- writeFileSync(temporaryPath, content, 'utf8');
67
- renameSync(temporaryPath, path);
78
+ const lockPath = `${path}.lock`;
79
+ let lockAcquired = false;
80
+ try {
81
+ try {
82
+ writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
83
+ lockAcquired = true;
84
+ }
85
+ catch (error) {
86
+ if (error.code === 'EEXIST') {
87
+ try {
88
+ if (Date.now() - statSync(lockPath).mtimeMs > 60_000) {
89
+ unlinkSync(lockPath);
90
+ writeFileSync(lockPath, `${process.pid}\n`, { encoding: 'utf8', flag: 'wx' });
91
+ lockAcquired = true;
92
+ }
93
+ }
94
+ catch {
95
+ // Another writer may have replaced or removed the lock.
96
+ }
97
+ }
98
+ if (!lockAcquired)
99
+ throw new GuardStateBusyError(path);
100
+ }
101
+ const temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;
102
+ writeFileSync(temporaryPath, content, 'utf8');
103
+ renameSync(temporaryPath, path);
104
+ }
105
+ finally {
106
+ if (lockAcquired)
107
+ unlinkSync(lockPath);
108
+ }
68
109
  }
69
110
  function writeGuardArtifact(root, relativePath, value) {
70
111
  mkdirSync(resolve(root, GUARD_DIR), { recursive: true });
@@ -108,14 +149,17 @@ export function getGuardProposalFreshness(model, proposals) {
108
149
  return proposals.projectFingerprint === fingerprintProjectModel(model) ? 'current' : 'stale';
109
150
  }
110
151
  export function loadGuardConfig(root) {
111
- const value = readJson(resolve(root, GUARD_RULES_FILE));
112
- if (!isGuardConfig(value))
152
+ const rulesPath = resolve(root, GUARD_RULES_FILE);
153
+ if (!existsSync(rulesPath))
113
154
  return undefined;
155
+ const value = readJson(rulesPath);
156
+ if (!isGuardConfig(value))
157
+ throw new GuardConfigError(GUARD_RULES_FILE);
114
158
  const contractsPath = resolve(root, GUARD_CONTRACTS_FILE);
115
159
  if (existsSync(contractsPath)) {
116
160
  const contractFile = readJson(contractsPath);
117
161
  if (!isGuardContractsFile(contractFile))
118
- return undefined;
162
+ throw new GuardConfigError(GUARD_CONTRACTS_FILE);
119
163
  return { ...value, contracts: contractFile.contracts };
120
164
  }
121
165
  return {
@@ -137,9 +181,6 @@ export function loadGuardAgentConfig(root) {
137
181
  export function writeGuardAgentConfig(root, agentConfig = defaultGuardAgentConfig) {
138
182
  writeGuardArtifact(root, GUARD_AGENT_FILE, agentConfig);
139
183
  }
140
- export function loadGuardArtifact(root, relativePath) {
141
- return readJson(resolve(root, relativePath));
142
- }
143
184
  export function loadBaseline(root) {
144
185
  const value = readJson(resolve(root, GUARD_BASELINE_FILE));
145
186
  if (!Array.isArray(value))
@@ -202,6 +243,31 @@ export function recordGuardProposalDecision(root, decisions) {
202
243
  decisions: [...(proposals.decisions ?? []), ...nextDecisions],
203
244
  });
204
245
  }
246
+ export function getPendingGuardProposals(proposals) {
247
+ if (!proposals)
248
+ return [];
249
+ const decisions = new Map();
250
+ for (const decision of proposals.decisions ?? []) {
251
+ const current = decisions.get(`${decision.type}:${decision.id}`);
252
+ if (!current || decision.decidedAt >= current.decidedAt) {
253
+ decisions.set(`${decision.type}:${decision.id}`, decision);
254
+ }
255
+ }
256
+ const matchesCurrentProposal = (decision) => decision !== undefined &&
257
+ (proposals.proposalId === undefined || decision.proposalId === proposals.proposalId) &&
258
+ (proposals.contentFingerprint === undefined ||
259
+ decision.proposalFingerprint === proposals.contentFingerprint) &&
260
+ (proposals.revision === undefined || decision.revision === proposals.revision);
261
+ return [
262
+ ...proposals.rules
263
+ .filter((rule) => rule.status === 'proposed' && !matchesCurrentProposal(decisions.get(`rule:${rule.id}`)))
264
+ .map((rule) => ({ id: rule.id, type: 'rule' })),
265
+ ...proposals.contracts
266
+ .filter((contract) => contract.status === 'proposed' &&
267
+ !matchesCurrentProposal(decisions.get(`contract:${contract.id}`)))
268
+ .map((contract) => ({ id: contract.id, type: 'contract' })),
269
+ ];
270
+ }
205
271
  export function buildGuardProposals(model, guardConfig) {
206
272
  const capabilities = Object.keys(model.capabilities);
207
273
  const questions = [];
@@ -347,14 +413,19 @@ export function buildConventionsMemory(model) {
347
413
  }
348
414
  export function buildGeneratedGuardConfig(model) {
349
415
  const isPersistenceImport = (importPath) => /(?:^|\/)(?:database|db)(?:\/|$)/i.test(importPath) ||
350
- /^(?:@prisma\codapult-guardent|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
416
+ /^(?:@prisma\/client|prisma|drizzle-orm|drizzle-kit|typeorm|sequelize|mongoose|knex)(?:\/|$)/i.test(importPath);
417
+ const moduleReferences = (module) => [
418
+ ...module.imports,
419
+ ...module.exports,
420
+ ...module.dynamicImports,
421
+ ];
351
422
  const persistenceImports = [
352
- ...new Set(model.modules.flatMap((module) => module.imports).filter(isPersistenceImport)),
423
+ ...new Set(model.modules.flatMap((module) => moduleReferences(module)).filter(isPersistenceImport)),
353
424
  ].sort();
354
425
  const clientPersistenceFiles = model.insights.boundaries
355
426
  .find((boundary) => boundary.kind === 'client')
356
427
  ?.files.filter((file) => model.modules.some((module) => module.path === file &&
357
- module.imports.some((importPath) => persistenceImports.includes(importPath)))) ?? [];
428
+ moduleReferences(module).some((importPath) => persistenceImports.includes(importPath)))) ?? [];
358
429
  const proposedRules = clientPersistenceFiles.length > 0
359
430
  ? [
360
431
  {
@@ -424,6 +495,32 @@ export function writeProjectSnapshot(root, model) {
424
495
  atomicWriteFile(resolve(root, GUARD_HISTORY_DIR, `${revision}.json`), `${JSON.stringify(model, null, 2)}\n`);
425
496
  return revision;
426
497
  }
498
+ function isProjectPath(root, value) {
499
+ const normalized = value.replace(/\\/g, '/');
500
+ if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
501
+ return false;
502
+ }
503
+ try {
504
+ const projectRoot = `${realpathSync(root)}${sep}`;
505
+ const targetPath = resolve(root, normalized);
506
+ let target;
507
+ try {
508
+ target = realpathSync(targetPath);
509
+ }
510
+ catch {
511
+ target = realpathSync(dirname(targetPath));
512
+ }
513
+ return target === projectRoot.slice(0, -1) || target.startsWith(projectRoot);
514
+ }
515
+ catch {
516
+ return false;
517
+ }
518
+ }
519
+ export function loadGuardArtifact(root, relativePath) {
520
+ if (!isProjectPath(root, relativePath))
521
+ return undefined;
522
+ return readJson(resolve(root, relativePath));
523
+ }
427
524
  export function validateGuardContracts(root, contracts = []) {
428
525
  const issues = [];
429
526
  for (const contract of contracts) {
@@ -445,8 +542,18 @@ export function validateGuardContracts(root, contracts = []) {
445
542
  message: 'Required-call contracts need at least one mustCall pattern.',
446
543
  });
447
544
  }
545
+ if (contract.kind === 'package-boundary' &&
546
+ ((contract.fromPackages?.length ?? 0) === 0 ||
547
+ (contract.mustNotImportPackages?.length ?? 0) === 0)) {
548
+ issues.push({
549
+ contractId: contract.id,
550
+ field: 'definition',
551
+ value: contract.kind,
552
+ message: 'Package-boundary contracts need fromPackages and mustNotImportPackages patterns.',
553
+ });
554
+ }
448
555
  for (const scope of contract.scope ?? []) {
449
- if (!existsSync(resolve(root, scope))) {
556
+ if (!isProjectPath(root, scope) || !existsSync(resolve(root, scope))) {
450
557
  issues.push({
451
558
  contractId: contract.id,
452
559
  field: 'scope',
@@ -456,7 +563,7 @@ export function validateGuardContracts(root, contracts = []) {
456
563
  }
457
564
  }
458
565
  for (const entrypoint of contract.entrypoints ?? []) {
459
- if (!existsSync(resolve(root, entrypoint))) {
566
+ if (!isProjectPath(root, entrypoint) || !existsSync(resolve(root, entrypoint))) {
460
567
  issues.push({
461
568
  contractId: contract.id,
462
569
  field: 'scope',
@@ -466,7 +573,7 @@ export function validateGuardContracts(root, contracts = []) {
466
573
  }
467
574
  }
468
575
  for (const excluded of contract.exclude ?? []) {
469
- if (!existsSync(resolve(root, excluded))) {
576
+ if (!isProjectPath(root, excluded) || !existsSync(resolve(root, excluded))) {
470
577
  issues.push({
471
578
  contractId: contract.id,
472
579
  field: 'scope',
@@ -476,7 +583,7 @@ export function validateGuardContracts(root, contracts = []) {
476
583
  }
477
584
  }
478
585
  for (const reference of contract.references ?? []) {
479
- if (!existsSync(resolve(root, reference))) {
586
+ if (!isProjectPath(root, reference) || !existsSync(resolve(root, reference))) {
480
587
  issues.push({
481
588
  contractId: contract.id,
482
589
  field: 'reference',
@@ -541,6 +648,32 @@ function importLine(sourceFile, importPath) {
541
648
  });
542
649
  return dynamicImport?.getStartLineNumber() ?? 1;
543
650
  }
651
+ function moduleImportReferences(module) {
652
+ const sources = [...new Set([...module.imports, ...module.exports, ...module.dynamicImports])];
653
+ return sources.map((source) => ({ source, resolved: module.resolvedImportMap?.[source] }));
654
+ }
655
+ function packageSelectorMatches(value, selector) {
656
+ if (!value)
657
+ return false;
658
+ return selector.endsWith('*') ? value.startsWith(selector.slice(0, -1)) : value === selector;
659
+ }
660
+ function packageForFile(model, file) {
661
+ const packageInfo = model.project.workspacePackages
662
+ .filter((workspace) => workspace.path === '.' ||
663
+ file === `${workspace.path}/package.json` ||
664
+ file.startsWith(`${workspace.path}/`))
665
+ .sort((left, right) => right.path.length - left.path.length)[0] ?? null;
666
+ return packageInfo === null ? undefined : (packageInfo.name ?? packageInfo.path);
667
+ }
668
+ function packageForImport(model, reference) {
669
+ const resolvedPackage = reference.resolved
670
+ ? packageForFile(model, reference.resolved)
671
+ : undefined;
672
+ if (resolvedPackage)
673
+ return resolvedPackage;
674
+ return model.project.workspacePackages.find((workspace) => workspace.name === reference.source ||
675
+ (workspace.name !== undefined && reference.source.startsWith(`${workspace.name}/`)))?.name;
676
+ }
544
677
  function scanFile(root, file, rules, module, astProject) {
545
678
  if (!module)
546
679
  return [];
@@ -555,26 +688,25 @@ function scanFile(root, file, rules, module, astProject) {
555
688
  }
556
689
  }
557
690
  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;
691
+ for (const reference of moduleImportReferences(module)) {
692
+ const line = sourceFile ? importLine(sourceFile, reference.source) : 1;
563
693
  for (const rule of rules) {
564
694
  if (rule.status === 'proposed' || !ruleAppliesToFile(rule, file))
565
695
  continue;
566
696
  if (rule.kind === 'client-forbidden-import' && !isClient)
567
697
  continue;
568
- if (!rule.patterns.some((pattern) => patternMatches(importPath, pattern)))
698
+ if (!rule.patterns.some((pattern) => patternMatches(reference.source, pattern) ||
699
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))
569
700
  continue;
570
701
  findings.push({
571
702
  ruleId: rule.id,
572
703
  severity: rule.severity,
573
704
  file,
574
705
  line,
575
- importPath,
706
+ importPath: reference.source,
707
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
576
708
  message: rule.description,
577
- fingerprint: `${rule.id}|${file}|${importPath}`,
709
+ fingerprint: `${rule.id}|${file}|${reference.source}|${reference.resolved ?? ''}`,
578
710
  });
579
711
  }
580
712
  }
@@ -589,22 +721,25 @@ function scanContracts(root, contracts, changed) {
589
721
  const severity = contract.severity ?? 'error';
590
722
  for (const module of model.modules.filter((item) => (!changed || changed.has(item.path)) && contractAppliesToFile(contract, item.path))) {
591
723
  if (contract.kind === 'import-boundary') {
592
- for (const importPath of module.imports) {
593
- if (contract.mustNotImport?.some((pattern) => patternMatches(importPath, pattern))) {
724
+ for (const reference of moduleImportReferences(module)) {
725
+ if (contract.mustNotImport?.some((pattern) => patternMatches(reference.source, pattern) ||
726
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern)))) {
594
727
  findings.push({
595
728
  ruleId: `contract:${contract.id}`,
596
729
  severity,
597
730
  file: module.path,
598
731
  line: 1,
599
- importPath,
732
+ importPath: reference.source,
733
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
600
734
  message: contract.statement,
601
- fingerprint: `contract-import|${contract.id}|${module.path}|${importPath}`,
735
+ fingerprint: `contract-import|${contract.id}|${module.path}|${reference.source}|${reference.resolved ?? ''}`,
602
736
  });
603
737
  }
604
738
  }
605
739
  if (contract.mustImport !== undefined &&
606
740
  contract.mustImport.length > 0 &&
607
- !module.imports.some((importPath) => contract.mustImport?.some((pattern) => patternMatches(importPath, pattern)))) {
741
+ !moduleImportReferences(module).some((reference) => contract.mustImport?.some((pattern) => patternMatches(reference.source, pattern) ||
742
+ (reference.resolved !== undefined && patternMatches(reference.resolved, pattern))))) {
608
743
  findings.push({
609
744
  ruleId: `contract:${contract.id}`,
610
745
  severity,
@@ -630,6 +765,28 @@ function scanContracts(root, contracts, changed) {
630
765
  fingerprint: `contract-required-call|${contract.id}|${module.path}`,
631
766
  });
632
767
  }
768
+ if (contract.kind === 'package-boundary') {
769
+ const sourcePackage = packageForFile(model, module.path);
770
+ if (!contract.fromPackages?.some((selector) => packageSelectorMatches(sourcePackage, selector))) {
771
+ continue;
772
+ }
773
+ for (const reference of moduleImportReferences(module)) {
774
+ const targetPackage = packageForImport(model, reference);
775
+ if (!contract.mustNotImportPackages?.some((selector) => packageSelectorMatches(targetPackage, selector))) {
776
+ continue;
777
+ }
778
+ findings.push({
779
+ ruleId: `contract:${contract.id}`,
780
+ severity,
781
+ file: module.path,
782
+ line: 1,
783
+ importPath: reference.source,
784
+ ...(reference.resolved ? { resolvedPath: reference.resolved } : {}),
785
+ message: contract.statement,
786
+ fingerprint: `contract-package|${contract.id}|${module.path}|${reference.source}|${targetPackage ?? ''}`,
787
+ });
788
+ }
789
+ }
633
790
  }
634
791
  }
635
792
  return findings;
@@ -657,6 +814,30 @@ function changedFiles(root) {
657
814
  return undefined;
658
815
  }
659
816
  }
817
+ function changedImpactScope(model, changed) {
818
+ if (!changed)
819
+ return undefined;
820
+ const graph = buildModuleTargetGraph(model.modules);
821
+ const reverse = new Map();
822
+ for (const [from, targets] of graph) {
823
+ for (const target of targets)
824
+ reverse.set(target, [...(reverse.get(target) ?? []), from]);
825
+ }
826
+ const scope = new Set(changed);
827
+ const queue = [...changed];
828
+ while (queue.length > 0) {
829
+ const current = queue.shift();
830
+ if (!current)
831
+ continue;
832
+ for (const dependent of reverse.get(current) ?? []) {
833
+ if (!scope.has(dependent)) {
834
+ scope.add(dependent);
835
+ queue.push(dependent);
836
+ }
837
+ }
838
+ }
839
+ return scope;
840
+ }
660
841
  function architectureInsightFindings(root, changed) {
661
842
  const model = discoverProject(root);
662
843
  const findings = model.insights.cycles
@@ -677,7 +858,9 @@ function architectureInsightFindings(root, changed) {
677
858
  for (const module of model.modules) {
678
859
  if (!clientFiles.has(module.path))
679
860
  continue;
680
- for (const importPath of module.imports.filter((value) => serverOnlyImports.test(value))) {
861
+ for (const importPath of [
862
+ ...new Set([...module.imports, ...module.exports, ...module.dynamicImports]),
863
+ ].filter((value) => serverOnlyImports.test(value))) {
681
864
  if (changed && !changed.has(module.path))
682
865
  continue;
683
866
  findings.push({
@@ -716,25 +899,7 @@ function isSafeReviewFile(file) {
716
899
  /\.(?:pem|key|p12|pfx)$/i.test(file));
717
900
  }
718
901
  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
- }
902
+ return isProjectPath(root, file);
738
903
  }
739
904
  function parseGitChanges(root, args) {
740
905
  try {
@@ -780,6 +945,9 @@ export function redactSensitiveText(value) {
780
945
  };
781
946
  replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, '[REDACTED PRIVATE KEY]');
782
947
  replace(/\b(?:sk_(?:live|test)_|pk_(?:live|test)_|AKIA|gh[pousr]_|github_pat_)[A-Za-z0-9_-]+/g, '[REDACTED TOKEN]');
948
+ replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[REDACTED JWT]');
949
+ replace(/\bhttps?:\/\/[^\s/@:]+:[^\s/@]+@/gi, '[REDACTED URL CREDENTIALS]@');
950
+ replace(/\b(?:AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[0-9A-Za-z-]{10,})\b/g, '[REDACTED TOKEN]');
783
951
  replace(/((?:api[_-]?key|secret|token|password|authorization|database[_-]?url)\s*[:=]\s*["']?)[^\s"'`,}]+/gi, '$1[REDACTED]');
784
952
  return { value: redactedValue, redacted, redactionCount };
785
953
  }
@@ -898,9 +1066,10 @@ function reviewDiff(root, maxChars, base) {
898
1066
  }
899
1067
  }
900
1068
  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
1069
  const model = discoverProject(root);
1070
+ const changed = options.changedOnly ? (options.changedFiles ?? changedFiles(root)) : undefined;
1071
+ const scope = changedImpactScope(model, changed);
1072
+ const files = listSourceFiles(root).filter((file) => !scope || scope.has(file));
904
1073
  const modules = new Map(model.modules.map((module) => [module.path, module]));
905
1074
  const astProject = (() => {
906
1075
  try {
@@ -912,8 +1081,8 @@ export function scanGuard(root, guardConfig, options = {}) {
912
1081
  })();
913
1082
  const allFindings = [
914
1083
  ...files.flatMap((file) => scanFile(root, file, guardConfig.rules, modules.get(file), astProject)),
915
- ...scanContracts(root, guardConfig.contracts ?? [], changed),
916
- ...(options.includeArchitectureInsights ? architectureInsightFindings(root, changed) : []),
1084
+ ...scanContracts(root, guardConfig.contracts ?? [], scope),
1085
+ ...(options.includeArchitectureInsights ? architectureInsightFindings(root, scope) : []),
917
1086
  ];
918
1087
  const baseline = options.baseline ?? new Set();
919
1088
  const findings = allFindings.filter((finding) => !baseline.has(finding.fingerprint));
@@ -983,4 +1152,4 @@ export function initializeGuard(root, options = {}) {
983
1152
  writeBaseline(root, initialReport.findings, projectModel);
984
1153
  return { config: guardConfig, report: { ...initialReport, findings: [] } };
985
1154
  }
986
- export { discoverProject, discoverProjectWithMetrics, findGuardRoot };
1155
+ export { clearDiscoveryCache, discoverProject, discoverProjectWithMetrics, findGuardRoot };
@@ -30,6 +30,7 @@ export declare const guardContractSchema: z.ZodObject<{
30
30
  kind: z.ZodOptional<z.ZodEnum<{
31
31
  guidance: "guidance";
32
32
  "import-boundary": "import-boundary";
33
+ "package-boundary": "package-boundary";
33
34
  "required-call": "required-call";
34
35
  }>>;
35
36
  severity: z.ZodOptional<z.ZodEnum<{
@@ -45,6 +46,8 @@ export declare const guardContractSchema: z.ZodObject<{
45
46
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
46
47
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
47
48
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
49
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
50
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
48
51
  status: z.ZodOptional<z.ZodEnum<{
49
52
  active: "active";
50
53
  proposed: "proposed";
@@ -89,6 +92,7 @@ export declare const guardConfigSchema: z.ZodObject<{
89
92
  kind: z.ZodOptional<z.ZodEnum<{
90
93
  guidance: "guidance";
91
94
  "import-boundary": "import-boundary";
95
+ "package-boundary": "package-boundary";
92
96
  "required-call": "required-call";
93
97
  }>>;
94
98
  severity: z.ZodOptional<z.ZodEnum<{
@@ -104,6 +108,8 @@ export declare const guardConfigSchema: z.ZodObject<{
104
108
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
105
109
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
106
110
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
111
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
112
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
107
113
  status: z.ZodOptional<z.ZodEnum<{
108
114
  active: "active";
109
115
  proposed: "proposed";
@@ -124,6 +130,7 @@ export declare const guardContractsFileSchema: z.ZodObject<{
124
130
  kind: z.ZodOptional<z.ZodEnum<{
125
131
  guidance: "guidance";
126
132
  "import-boundary": "import-boundary";
133
+ "package-boundary": "package-boundary";
127
134
  "required-call": "required-call";
128
135
  }>>;
129
136
  severity: z.ZodOptional<z.ZodEnum<{
@@ -139,6 +146,8 @@ export declare const guardContractsFileSchema: z.ZodObject<{
139
146
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
140
147
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
141
148
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
149
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
150
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
142
151
  status: z.ZodOptional<z.ZodEnum<{
143
152
  active: "active";
144
153
  proposed: "proposed";
@@ -204,6 +213,7 @@ export declare const guardProposalSchema: z.ZodObject<{
204
213
  kind: z.ZodOptional<z.ZodEnum<{
205
214
  guidance: "guidance";
206
215
  "import-boundary": "import-boundary";
216
+ "package-boundary": "package-boundary";
207
217
  "required-call": "required-call";
208
218
  }>>;
209
219
  severity: z.ZodOptional<z.ZodEnum<{
@@ -219,6 +229,8 @@ export declare const guardProposalSchema: z.ZodObject<{
219
229
  mustImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
220
230
  mustNotImport: z.ZodOptional<z.ZodArray<z.ZodString>>;
221
231
  mustCall: z.ZodOptional<z.ZodArray<z.ZodString>>;
232
+ fromPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
233
+ mustNotImportPackages: z.ZodOptional<z.ZodArray<z.ZodString>>;
222
234
  status: z.ZodOptional<z.ZodEnum<{
223
235
  active: "active";
224
236
  proposed: "proposed";
@@ -2,29 +2,31 @@ import { z } from 'zod';
2
2
  const severity = z.enum(['error', 'warning', 'info']);
3
3
  const status = z.enum(['active', 'proposed']);
4
4
  export const guardRuleSchema = z.object({
5
- id: z.string(),
6
- description: z.string(),
5
+ id: z.string().trim().min(1),
6
+ description: z.string().trim().min(1),
7
7
  severity,
8
8
  kind: z.enum(['forbidden-import', 'client-forbidden-import']),
9
- patterns: z.array(z.string()),
10
- files: z.array(z.string()).optional(),
9
+ patterns: z.array(z.string().trim().min(1)).min(1),
10
+ files: z.array(z.string().trim().min(1)).optional(),
11
11
  status: status.optional(),
12
12
  confidence: z.enum(['high', 'medium', 'low']).optional(),
13
13
  evidence: z.array(z.string()).optional(),
14
14
  });
15
15
  export const guardContractSchema = z.object({
16
- id: z.string(),
17
- statement: z.string(),
18
- kind: z.enum(['guidance', 'import-boundary', 'required-call']).optional(),
16
+ id: z.string().trim().min(1),
17
+ statement: z.string().trim().min(1),
18
+ kind: z.enum(['guidance', 'import-boundary', 'required-call', 'package-boundary']).optional(),
19
19
  severity: severity.optional(),
20
- scope: z.array(z.string()).optional(),
21
- entrypoints: z.array(z.string()).optional(),
22
- exclude: z.array(z.string()).optional(),
23
- guidance: z.array(z.string()).optional(),
24
- references: z.array(z.string()).optional(),
25
- mustImport: z.array(z.string()).optional(),
26
- mustNotImport: z.array(z.string()).optional(),
27
- mustCall: z.array(z.string()).optional(),
20
+ scope: z.array(z.string().trim().min(1)).optional(),
21
+ entrypoints: z.array(z.string().trim().min(1)).optional(),
22
+ exclude: z.array(z.string().trim().min(1)).optional(),
23
+ guidance: z.array(z.string().trim().min(1)).optional(),
24
+ references: z.array(z.string().trim().min(1)).optional(),
25
+ mustImport: z.array(z.string().trim().min(1)).optional(),
26
+ mustNotImport: z.array(z.string().trim().min(1)).optional(),
27
+ mustCall: z.array(z.string().trim().min(1)).optional(),
28
+ fromPackages: z.array(z.string().trim().min(1)).optional(),
29
+ mustNotImportPackages: z.array(z.string().trim().min(1)).optional(),
28
30
  status: status.optional(),
29
31
  confidence: z.enum(['high', 'medium', 'low']).optional(),
30
32
  evidence: z.array(z.string()).optional(),
@@ -39,7 +41,7 @@ export const guardContractsFileSchema = z.object({
39
41
  contracts: z.array(guardContractSchema),
40
42
  });
41
43
  export const guardProposalDecisionSchema = z.object({
42
- id: z.string(),
44
+ id: z.string().trim().min(1),
43
45
  type: z.enum(['rule', 'contract']),
44
46
  decision: z.enum(['approved', 'rejected']),
45
47
  decidedAt: z.string(),
@@ -62,7 +64,9 @@ export const guardProposalSchema = z.object({
62
64
  export const guardAgentConfigSchema = z.object({
63
65
  version: z.literal(1),
64
66
  tools: z.enum(['auto', 'on', 'off']),
65
- tooling: z.record(z.string(), z.object({ script: z.string(), enabled: z.boolean() })).default({}),
67
+ tooling: z
68
+ .record(z.string().trim().min(1), z.object({ script: z.string().trim().min(1), enabled: z.boolean() }))
69
+ .default({}),
66
70
  completionGate: z.object({
67
71
  enabled: z.boolean(),
68
72
  maxIterations: z.number().int().min(1).max(10),
@@ -1,16 +1,21 @@
1
1
  import { type CommandResult } from '../../adapters/command.js';
2
2
  import { type GuardAdapter, type ProjectCheck, type ProjectCheckResults, type ProjectRuntimeDiagnostics } from '../../adapters/project-checks.js';
3
3
  import { type GuardToolMode, type GuardContractIssue, type GuardReport } from '../guard.js';
4
+ import { type GuardImpactAnalysis } from '../analysis/impact.js';
4
5
  export type GuardVerificationCheck = ProjectCheck;
5
6
  export interface GuardVerificationResult {
6
7
  status: 'ok' | 'fail' | 'not-configured';
7
8
  outcome: 'pass' | 'fail' | 'warning' | 'needs-review' | 'not-configured';
9
+ errorCode?: 'GUARD_NOT_CONFIGURED' | 'GUARD_CONFIG_INVALID';
10
+ recoverable?: boolean;
8
11
  tools?: GuardToolMode;
9
12
  checks: ProjectCheckResults;
10
13
  workspaceChecks: Record<string, ProjectCheckResults>;
11
14
  adapters: Partial<Record<GuardAdapter, CommandResult>>;
12
15
  runtime: ProjectRuntimeDiagnostics;
16
+ configError?: string;
13
17
  architecture?: GuardReport;
18
+ impact?: GuardImpactAnalysis;
14
19
  contractIssues: GuardContractIssue[];
15
20
  requirement: {
16
21
  status: 'delegated-to-review';