@delegance/claude-autopilot 2.5.0 → 5.0.0-alpha.2

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 (129) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +169 -106
  3. package/bin/_launcher.js +77 -0
  4. package/bin/claude-autopilot.js +3 -0
  5. package/bin/guardrail.js +3 -0
  6. package/package.json +23 -9
  7. package/presets/generic/guardrail.config.yaml +35 -0
  8. package/presets/generic/stack.md +40 -0
  9. package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
  10. package/scripts/autoregress.ts +27 -11
  11. package/skills/autopilot/SKILL.md +170 -0
  12. package/skills/claude-autopilot.md +80 -0
  13. package/skills/guardrail.md +39 -0
  14. package/skills/migrate/SKILL.md +83 -0
  15. package/src/adapters/council/claude.ts +41 -0
  16. package/src/adapters/council/openai.ts +40 -0
  17. package/src/adapters/council/types.ts +7 -0
  18. package/src/adapters/loader.ts +7 -7
  19. package/src/adapters/review-engine/auto.ts +2 -2
  20. package/src/adapters/review-engine/claude.ts +9 -11
  21. package/src/adapters/review-engine/codex.ts +9 -11
  22. package/src/adapters/review-engine/gemini.ts +9 -11
  23. package/src/adapters/review-engine/openai-compatible.ts +10 -12
  24. package/src/adapters/review-engine/parse-output.ts +32 -6
  25. package/src/adapters/review-engine/prompt-builder.ts +19 -0
  26. package/src/adapters/review-engine/types.ts +1 -1
  27. package/src/adapters/vcs-host/commit-status.ts +39 -0
  28. package/src/adapters/vcs-host/github.ts +2 -2
  29. package/src/cli/baseline.ts +125 -0
  30. package/src/cli/ci.ts +11 -8
  31. package/src/cli/costs.ts +2 -2
  32. package/src/cli/council.ts +96 -0
  33. package/src/cli/detector.ts +21 -5
  34. package/src/cli/explain.ts +197 -0
  35. package/src/cli/fix.ts +173 -111
  36. package/src/cli/hook.ts +72 -27
  37. package/src/cli/ignore-helper.ts +116 -0
  38. package/src/cli/index.ts +355 -31
  39. package/src/cli/init.ts +12 -12
  40. package/src/cli/lsp.ts +200 -0
  41. package/src/cli/mcp.ts +206 -0
  42. package/src/cli/pr-comment.ts +5 -5
  43. package/src/cli/pr-desc.ts +168 -0
  44. package/src/cli/pr-review-comments.ts +3 -3
  45. package/src/cli/pr.ts +76 -0
  46. package/src/cli/preflight.ts +109 -32
  47. package/src/cli/report.ts +186 -0
  48. package/src/cli/run.ts +140 -36
  49. package/src/cli/scan.ts +233 -0
  50. package/src/cli/setup.ts +121 -15
  51. package/src/cli/test-gen.ts +125 -0
  52. package/src/cli/triage.ts +137 -0
  53. package/src/cli/watch.ts +52 -31
  54. package/src/cli/worker.ts +109 -0
  55. package/src/core/cache/review-cache.ts +2 -2
  56. package/src/core/chunking/index.ts +2 -2
  57. package/src/core/config/loader.ts +10 -10
  58. package/src/core/config/preset-resolver.ts +6 -6
  59. package/src/core/config/schema.ts +103 -2
  60. package/src/core/config/types.ts +57 -2
  61. package/src/core/council/config.ts +71 -0
  62. package/src/core/council/context.ts +17 -0
  63. package/src/core/council/runner.ts +83 -0
  64. package/src/core/council/types.ts +45 -0
  65. package/src/core/detect/llm-key.ts +89 -0
  66. package/src/core/detect/workspaces.ts +103 -0
  67. package/src/core/errors.ts +4 -4
  68. package/src/core/fix/generator.ts +149 -0
  69. package/src/core/ignore/index.ts +4 -4
  70. package/src/core/mcp/concurrency.ts +16 -0
  71. package/src/core/mcp/handlers/fix-finding.ts +126 -0
  72. package/src/core/mcp/handlers/get-capabilities.ts +62 -0
  73. package/src/core/mcp/handlers/get-findings.ts +36 -0
  74. package/src/core/mcp/handlers/review-diff.ts +65 -0
  75. package/src/core/mcp/handlers/scan-files.ts +65 -0
  76. package/src/core/mcp/handlers/validate-fix.ts +41 -0
  77. package/src/core/mcp/run-store.ts +85 -0
  78. package/src/core/mcp/workspace.ts +35 -0
  79. package/src/core/persist/baseline.ts +112 -0
  80. package/src/core/persist/cost-log.ts +1 -1
  81. package/src/core/persist/findings-cache.ts +1 -1
  82. package/src/core/persist/triage.ts +112 -0
  83. package/src/core/phases/static-rules.ts +18 -5
  84. package/src/core/pipeline/review-phase.ts +65 -26
  85. package/src/core/pipeline/run.ts +42 -10
  86. package/src/core/runtime/lock.ts +2 -2
  87. package/src/core/runtime/state.ts +2 -2
  88. package/src/core/schema-alignment/detector.ts +59 -0
  89. package/src/core/schema-alignment/extractor/index.ts +24 -0
  90. package/src/core/schema-alignment/extractor/prisma.ts +21 -0
  91. package/src/core/schema-alignment/extractor/sql.ts +99 -0
  92. package/src/core/schema-alignment/llm-check.ts +91 -0
  93. package/src/core/schema-alignment/scanner.ts +107 -0
  94. package/src/core/schema-alignment/types.ts +43 -0
  95. package/src/core/shell.ts +3 -3
  96. package/src/core/static-rules/registry.ts +17 -8
  97. package/src/core/static-rules/rules/brand-tokens.ts +145 -0
  98. package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
  99. package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
  100. package/src/core/static-rules/rules/missing-auth.ts +70 -0
  101. package/src/core/static-rules/rules/schema-alignment.ts +132 -0
  102. package/src/core/static-rules/rules/sql-injection.ts +71 -0
  103. package/src/core/static-rules/rules/ssrf.ts +63 -0
  104. package/src/core/static-rules/tailwind-extractor.ts +38 -0
  105. package/src/core/test-gen/coverage-analyzer.ts +93 -0
  106. package/src/core/test-gen/framework-detector.ts +21 -0
  107. package/src/core/test-gen/test-writer.ts +33 -0
  108. package/src/core/ui/design-context-loader.ts +87 -0
  109. package/src/core/worker/client.ts +46 -0
  110. package/src/core/worker/lockfile.ts +38 -0
  111. package/src/core/worker/server.ts +81 -0
  112. package/src/formatters/junit.ts +52 -0
  113. package/src/formatters/sarif.ts +2 -2
  114. package/src/index.ts +1 -2
  115. package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
  116. package/tests/snapshots/index.json +3 -3
  117. package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
  118. package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
  119. package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
  120. package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
  121. package/bin/autopilot.js +0 -20
  122. package/skills/autopilot.md +0 -157
  123. /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  124. /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  125. /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  126. /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  127. /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
  128. /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
  129. /package/{src → scripts}/snapshots/serializer.ts +0 -0
@@ -1,12 +1,15 @@
1
1
  import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
2
2
  import type { Finding } from '../findings/types.ts';
3
- import type { AutopilotConfig } from '../config/types.ts';
3
+ import type { GuardrailConfig } from '../config/types.ts';
4
4
  import { buildReviewChunks, type ReviewChunk } from '../chunking/index.ts';
5
+ import { GuardrailError } from '../errors.ts';
6
+ import { hasFrontendFiles, loadDesignContext } from '../ui/design-context-loader.ts';
5
7
 
6
8
  export interface ReviewPhaseResult {
7
9
  phase: 'review';
8
10
  status: 'pass' | 'warn' | 'fail' | 'skip';
9
11
  findings: Finding[];
12
+ rawOutputs?: string[];
10
13
  costUSD?: number;
11
14
  usage?: { input: number; output: number };
12
15
  durationMs: number;
@@ -15,32 +18,55 @@ export interface ReviewPhaseResult {
15
18
  export interface ReviewPhaseInput {
16
19
  touchedFiles: string[];
17
20
  engine: ReviewEngine;
18
- config: AutopilotConfig;
21
+ config: GuardrailConfig;
19
22
  cwd?: string;
20
23
  gitSummary?: string;
24
+ designSchema?: string;
21
25
  budgetRemainingUSD?: number;
22
26
  base?: string;
23
27
  }
24
28
 
25
29
  interface ChunkResult {
26
30
  findings: Finding[];
31
+ rawOutput: string;
27
32
  inputTokens: number;
28
33
  outputTokens: number;
29
34
  costUSD: number;
30
35
  }
31
36
 
32
- async function reviewChunk(chunk: ReviewChunk, input: ReviewPhaseInput): Promise<ChunkResult> {
33
- const output = await input.engine.review({
34
- content: chunk.content,
35
- kind: chunk.kind,
36
- context: { stack: input.config.stack, cwd: input.cwd, gitSummary: input.gitSummary },
37
- });
38
- return {
39
- findings: output.findings,
40
- inputTokens: output.usage?.input ?? 0,
41
- outputTokens: output.usage?.output ?? 0,
42
- costUSD: output.usage?.costUSD ?? 0,
43
- };
37
+ function backoffMs(attempt: number, strategy: 'exp' | 'linear' | 'none'): number {
38
+ if (strategy === 'none') return 0;
39
+ if (strategy === 'linear') return attempt * 2000;
40
+ return Math.min(Math.pow(2, attempt) * 1000, 32000); // exp: 1s, 2s, 4s, 8s … 32s cap
41
+ }
42
+
43
+ async function reviewChunkWithRetry(chunk: ReviewChunk, input: ReviewPhaseInput): Promise<ChunkResult> {
44
+ const strategy = input.config.chunking?.rateLimitBackoff ?? 'exp';
45
+ const maxAttempts = strategy === 'none' ? 1 : 4;
46
+
47
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
48
+ try {
49
+ const output = await input.engine.review({
50
+ content: chunk.content,
51
+ kind: chunk.kind,
52
+ context: { stack: input.config.stack, cwd: input.cwd, gitSummary: input.gitSummary, designSchema: input.designSchema },
53
+ });
54
+ return {
55
+ findings: output.findings,
56
+ rawOutput: output.rawOutput,
57
+ inputTokens: output.usage?.input ?? 0,
58
+ outputTokens: output.usage?.output ?? 0,
59
+ costUSD: output.usage?.costUSD ?? 0,
60
+ };
61
+ } catch (err) {
62
+ const isRateLimit = err instanceof GuardrailError && err.code === 'rate_limit';
63
+ const isLast = attempt === maxAttempts - 1;
64
+ if (!isRateLimit || isLast) throw err;
65
+ const delay = backoffMs(attempt + 1, strategy);
66
+ await new Promise(r => setTimeout(r, delay));
67
+ }
68
+ }
69
+ throw new Error('unreachable');
44
70
  }
45
71
 
46
72
  /** Run up to `limit` promises concurrently, preserving result order. */
@@ -71,18 +97,28 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
71
97
  return { phase: 'review', status: 'skip', findings: [], durationMs: Date.now() - start };
72
98
  }
73
99
 
100
+ let designSchema: string | undefined;
101
+ if (hasFrontendFiles(input.touchedFiles) && input.config.brand?.componentLibrary) {
102
+ const loaded = loadDesignContext(
103
+ input.config.brand.componentLibrary,
104
+ input.cwd ?? process.cwd(),
105
+ );
106
+ if (loaded) designSchema = loaded;
107
+ }
108
+ const enrichedInput: ReviewPhaseInput = designSchema ? { ...input, designSchema } : input;
109
+
74
110
  const chunks = await buildReviewChunks({
75
- touchedFiles: input.touchedFiles,
76
- strategy: input.config.reviewStrategy ?? 'auto',
77
- chunking: input.config.chunking,
78
- engine: input.engine,
79
- cwd: input.cwd,
80
- protectedPaths: input.config.protectedPaths,
81
- base: input.base,
111
+ touchedFiles: enrichedInput.touchedFiles,
112
+ strategy: enrichedInput.config.reviewStrategy ?? 'auto',
113
+ chunking: enrichedInput.config.chunking,
114
+ engine: enrichedInput.engine,
115
+ cwd: enrichedInput.cwd,
116
+ protectedPaths: enrichedInput.config.protectedPaths,
117
+ base: enrichedInput.base,
82
118
  });
83
119
 
84
- const parallelism = input.config.chunking?.parallelism ?? 3;
85
- const budgetUSD = input.budgetRemainingUSD;
120
+ const parallelism = enrichedInput.config.chunking?.parallelism ?? 3;
121
+ const budgetUSD = enrichedInput.budgetRemainingUSD;
86
122
 
87
123
  // For budget tracking we still need to enforce it — run serially if budget set,
88
124
  // parallel otherwise (budget check between serial chunks is the safe path).
@@ -93,7 +129,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
93
129
  let budgetExceeded = false;
94
130
  for (const chunk of chunks) {
95
131
  if (spent >= budgetUSD) { budgetExceeded = true; break; }
96
- const r = await reviewChunk(chunk, input);
132
+ const r = await reviewChunkWithRetry(chunk, enrichedInput);
97
133
  spent += r.costUSD;
98
134
  chunkResults.push(r);
99
135
  }
@@ -109,20 +145,22 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
109
145
  protectedPath: false,
110
146
  createdAt: new Date().toISOString(),
111
147
  }],
112
- inputTokens: 0, outputTokens: 0, costUSD: 0,
148
+ rawOutput: '', inputTokens: 0, outputTokens: 0, costUSD: 0,
113
149
  });
114
150
  }
115
151
  } else {
116
- chunkResults = await pMap(chunks, chunk => reviewChunk(chunk, input), parallelism);
152
+ chunkResults = await pMap(chunks, chunk => reviewChunkWithRetry(chunk, enrichedInput), parallelism);
117
153
  }
118
154
 
119
155
  let totalInputTokens = 0;
120
156
  let totalOutputTokens = 0;
121
157
  let totalCostUSD = 0;
122
158
  const allFindings: Finding[] = [];
159
+ const allRawOutputs: string[] = [];
123
160
 
124
161
  for (const r of chunkResults) {
125
162
  allFindings.push(...r.findings);
163
+ if (r.rawOutput) allRawOutputs.push(r.rawOutput);
126
164
  totalInputTokens += r.inputTokens;
127
165
  totalOutputTokens += r.outputTokens;
128
166
  totalCostUSD += r.costUSD;
@@ -136,6 +174,7 @@ export async function runReviewPhase(input: ReviewPhaseInput): Promise<ReviewPha
136
174
  phase: 'review',
137
175
  status,
138
176
  findings: allFindings,
177
+ rawOutputs: allRawOutputs.length > 0 ? allRawOutputs : undefined,
139
178
  costUSD: totalCostUSD > 0 ? totalCostUSD : undefined,
140
179
  usage: totalInputTokens > 0 ? { input: totalInputTokens, output: totalOutputTokens } : undefined,
141
180
  durationMs: Date.now() - start,
@@ -1,4 +1,4 @@
1
- import type { AutopilotConfig } from '../config/types.ts';
1
+ import type { GuardrailConfig } from '../config/types.ts';
2
2
  import type { StaticRule } from '../phases/static-rules.ts';
3
3
  import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
4
4
  import type { Finding } from '../findings/types.ts';
@@ -13,12 +13,13 @@ export type PhaseResult = StaticRulesPhaseResult | TestsPhaseResult | ReviewPhas
13
13
 
14
14
  export interface RunInput {
15
15
  touchedFiles: string[];
16
- config: AutopilotConfig;
16
+ config: GuardrailConfig;
17
17
  reviewEngine?: ReviewEngine;
18
18
  staticRules?: StaticRule[];
19
19
  cwd?: string;
20
20
  gitSummary?: string;
21
21
  base?: string;
22
+ skipReview?: boolean;
22
23
  }
23
24
 
24
25
  export interface RunResult {
@@ -29,33 +30,64 @@ export interface RunResult {
29
30
  durationMs: number;
30
31
  }
31
32
 
32
- export async function runAutopilot(input: RunInput): Promise<RunResult> {
33
+ export async function runGuardrail(input: RunInput): Promise<RunResult> {
33
34
  const start = Date.now();
34
35
  const phases: PhaseResult[] = [];
35
36
  let totalCostUSD: number | undefined;
36
37
 
37
- // Static-rules phase fail fast on critical
38
+ const pipelineCfg = input.config.pipeline ?? {};
39
+ // Default true: when the user wires up a review engine they expect it to actually run.
40
+ // Skipping LLM review on static-fail is exactly the "silent skip" behavior the v4.0
41
+ // reviewer flagged — the bugs the LLM is best at often ride alongside one a static
42
+ // rule already caught.
43
+ const runReviewOnStaticFail = pipelineCfg.runReviewOnStaticFail !== false;
44
+ const runReviewOnTestFail = pipelineCfg.runReviewOnTestFail === true;
45
+
46
+ // Static-rules phase — tests always run afterward, regardless of status. The
47
+ // runReviewOnStaticFail flag only gates the LLM review phase (matching its name);
48
+ // skipping tests on a static-fail would be surprising and asymmetric with
49
+ // runReviewOnTestFail which only gates the review phase too.
50
+ let staticFailed = false;
38
51
  if (input.staticRules && input.staticRules.length > 0) {
39
52
  const result = await runStaticRulesPhase({
40
53
  touchedFiles: input.touchedFiles,
41
54
  rules: input.staticRules,
55
+ config: input.config,
56
+ engine: input.reviewEngine,
42
57
  });
43
58
  phases.push(result);
44
- if (result.status === 'fail') return finalize(phases, start, totalCostUSD);
59
+ staticFailed = result.status === 'fail';
60
+ }
61
+
62
+ // skipReview short-circuit: skip tests and review phases entirely
63
+ if (input.skipReview) {
64
+ const allFindings = phases.flatMap(p => p.findings ?? []);
65
+ const hasCritical = allFindings.some(f => f.severity === 'critical');
66
+ return {
67
+ status: hasCritical ? 'fail' : 'pass',
68
+ phases,
69
+ allFindings,
70
+ totalCostUSD: undefined,
71
+ durationMs: Date.now() - start,
72
+ };
45
73
  }
46
74
 
47
- // Tests phase — fail fast on test failure
75
+ // Tests phase — always runs (unless skipReview above). The runReviewOnTestFail
76
+ // flag only gates the subsequent review phase.
48
77
  const testsResult = await runTestsPhase({
49
78
  touchedFiles: input.touchedFiles,
50
79
  testCommand: input.config.testCommand,
51
80
  cwd: input.cwd,
52
81
  });
53
82
  phases.push(testsResult);
54
- if (testsResult.status === 'fail') return finalize(phases, start, totalCostUSD);
83
+ const testsFailed = testsResult.status === 'fail';
55
84
 
56
- // Review phase (optional — only when engine is provided)
57
- if (input.reviewEngine) {
58
- const budgetUSD = (input.config.cost as { budgetUSD?: number } | undefined)?.budgetUSD;
85
+ // Review phase (optional — only when engine is provided, not gated off by flags)
86
+ const skipReviewDueToStatic = staticFailed && !runReviewOnStaticFail;
87
+ const skipReviewDueToTests = testsFailed && !runReviewOnTestFail;
88
+ if (input.reviewEngine && !skipReviewDueToStatic && !skipReviewDueToTests) {
89
+ const costCfg = input.config.cost as { maxPerRun?: number; budgetUSD?: number } | undefined;
90
+ const budgetUSD = costCfg?.maxPerRun ?? costCfg?.budgetUSD;
59
91
  const reviewResult = await runReviewPhase({
60
92
  touchedFiles: input.touchedFiles,
61
93
  engine: input.reviewEngine,
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import { AutopilotError } from '../errors.ts';
3
+ import { GuardrailError } from '../errors.ts';
4
4
 
5
5
  export interface LockHandle {
6
6
  release(): Promise<void>;
@@ -16,7 +16,7 @@ export function acquireLock(runId: string, lockDir = '.claude'): LockHandle {
16
16
  { flag: 'wx' }
17
17
  );
18
18
  } catch (err) {
19
- throw new AutopilotError('Another autopilot run holds the lock', {
19
+ throw new GuardrailError('Another autopilot run holds the lock', {
20
20
  code: 'concurrency_lock',
21
21
  details: { lockPath, cause: err instanceof Error ? err.message : String(err) },
22
22
  });
@@ -1,6 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
- import { AutopilotError } from '../errors.ts';
3
+ import { GuardrailError } from '../errors.ts';
4
4
 
5
5
  export type PipelineStep =
6
6
  | 'plan' | 'worktree' | 'implement' | 'migrate' | 'validate'
@@ -72,7 +72,7 @@ export async function loadRunState(runId: string, runsDir?: string): Promise<Run
72
72
  try {
73
73
  return JSON.parse(await fs.readFile(file, 'utf8')) as RunState;
74
74
  } catch (err) {
75
- throw new AutopilotError(`Run state not found: ${runId}`, {
75
+ throw new GuardrailError(`Run state not found: ${runId}`, {
76
76
  code: 'user_input',
77
77
  details: { runId, file, cause: err instanceof Error ? err.message : String(err) },
78
78
  });
@@ -0,0 +1,59 @@
1
+ // src/core/schema-alignment/detector.ts
2
+ import type { SchemaAlignmentConfig } from './types.ts';
3
+
4
+ const DEFAULT_PATTERNS: RegExp[] = [
5
+ /data[/\\]deltas[/\\].+\.sql$/,
6
+ /supabase[/\\]migrations[/\\].+\.sql$/,
7
+ /prisma[/\\]migrations[/\\].+\.sql$/,
8
+ /prisma[/\\]schema\.prisma$/,
9
+ /db[/\\]migrate[/\\].+\.rb$/,
10
+ /drizzle[/\\].+\.ts$/,
11
+ /[/\\]migrations[/\\].+\.py$/,
12
+ ];
13
+
14
+ function globToPattern(glob: string): RegExp {
15
+ let escaped = glob;
16
+
17
+ // Escape regex special chars except * and /
18
+ escaped = escaped.replace(/[.+^${}()|[\]\\]/g, '\\$&');
19
+
20
+ // Replace **/ with placeholder (matches zero or more intermediate directories)
21
+ escaped = escaped.replace(/\*\*\//g, '___DSTAR_SLASH___');
22
+
23
+ // Replace remaining ** with placeholder
24
+ escaped = escaped.replace(/\*\*/g, '___DSTAR___');
25
+
26
+ // Replace / with placeholder BEFORE handling * so we don't mess up character classes
27
+ escaped = escaped.replace(/\//g, '___SLASH___');
28
+
29
+ // Replace single * with placeholder (to preserve it before / restoration)
30
+ escaped = escaped.replace(/\*/g, '___STAR___');
31
+
32
+ // Now restore placeholders in the right order
33
+ // Restore / as [/\\\\] to match both forward and back slashes
34
+ // NOTE: In RegExp constructor, \\ becomes \, so [/\\] needs to be [/\\\\]
35
+ escaped = escaped.replace(/___SLASH___/g, '[/\\\\]');
36
+
37
+ // Restore * as [^/]* (matches anything except /)
38
+ escaped = escaped.replace(/___STAR___/g, '[^/]*');
39
+
40
+ // Restore **/ as optional directory segments with trailing separator
41
+ escaped = escaped.replace(/___DSTAR_SLASH___/g, '(?:.*[/\\\\])?');
42
+
43
+ // Restore remaining ** as .* (matches anything including /)
44
+ escaped = escaped.replace(/___DSTAR___/g, '.*');
45
+
46
+ const re = new RegExp(escaped + '$');
47
+ return re;
48
+ }
49
+
50
+ export function detect(touchedFiles: string[], config?: SchemaAlignmentConfig): string[] {
51
+ if (config?.enabled === false) return [];
52
+
53
+ const patterns = [...DEFAULT_PATTERNS];
54
+ for (const glob of config?.migrationGlobs ?? []) {
55
+ patterns.push(globToPattern(glob));
56
+ }
57
+
58
+ return touchedFiles.filter(f => patterns.some(re => re.test(f)));
59
+ }
@@ -0,0 +1,24 @@
1
+ // src/core/schema-alignment/extractor/index.ts
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import type { SchemaEntity } from '../types.ts';
5
+ import { extractFromSql } from './sql.ts';
6
+ import { extractFromPrisma } from './prisma.ts';
7
+
8
+ export function extract(filePath: string): SchemaEntity[] {
9
+ let content: string;
10
+ try {
11
+ content = fs.readFileSync(filePath, 'utf8');
12
+ } catch {
13
+ return [];
14
+ }
15
+
16
+ const ext = path.extname(filePath).toLowerCase();
17
+ const base = path.basename(filePath).toLowerCase();
18
+
19
+ if (ext === '.sql') return extractFromSql(content);
20
+ if (base === 'schema.prisma' || ext === '.prisma') return extractFromPrisma(content);
21
+
22
+ process.stderr.write(`[schema-alignment] no extractor for ${ext} files — skipping ${path.basename(filePath)}\n`);
23
+ return [];
24
+ }
@@ -0,0 +1,21 @@
1
+ // src/core/schema-alignment/extractor/prisma.ts
2
+ import type { SchemaEntity } from '../types.ts';
3
+
4
+ export function extractFromPrisma(content: string): SchemaEntity[] {
5
+ const entities: SchemaEntity[] = [];
6
+ // Match model blocks: model Name { ... }
7
+ const modelRe = /^model\s+(\w+)\s*\{([^}]+)\}/gm;
8
+ for (const modelMatch of content.matchAll(modelRe)) {
9
+ const table = modelMatch[1]!;
10
+ entities.push({ table, operation: 'create_table' });
11
+ const body = modelMatch[2]!;
12
+ // Match field lines: fieldName TypeName ...
13
+ const fieldRe = /^\s+(\w+)\s+\S/gm;
14
+ for (const fieldMatch of body.matchAll(fieldRe)) {
15
+ const column = fieldMatch[1]!;
16
+ if (column.startsWith('@') || column === 'id') continue;
17
+ entities.push({ table, column, operation: 'add_column' });
18
+ }
19
+ }
20
+ return entities;
21
+ }
@@ -0,0 +1,99 @@
1
+ // src/core/schema-alignment/extractor/sql.ts
2
+ import type { SchemaEntity } from '../types.ts';
3
+
4
+ function unquote(s: string | undefined): string {
5
+ if (!s) return '';
6
+ return s.replace(/^["'`]|["'`]$/g, '');
7
+ }
8
+
9
+ // Identifier: quoted or unquoted word (no schema prefix captured)
10
+ const ID = /(?:"([^"]+)"|`([^`]+)`|(\w+))/;
11
+ const SCHEMA_OPT = /(?:\w+\.)?/;
12
+
13
+ // Keywords that can follow ADD/DROP but are NOT columns
14
+ const NON_COLUMN_KEYWORDS = new Set([
15
+ 'CONSTRAINT', 'INDEX', 'PRIMARY', 'FOREIGN', 'UNIQUE', 'CHECK',
16
+ 'KEY', 'EXCLUDE', 'REFERENCES',
17
+ ]);
18
+
19
+ export function extractFromSql(content: string): SchemaEntity[] {
20
+ // Strip comments before processing
21
+ const normalized = content
22
+ .replace(/--[^\n]*/g, ' ')
23
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
24
+ .replace(/\s+/g, ' ');
25
+
26
+ const entities: SchemaEntity[] = [];
27
+
28
+ // CREATE TABLE [IF NOT EXISTS] [schema.]name
29
+ const createTableRe = new RegExp(
30
+ `CREATE\\s+(?:OR\\s+REPLACE\\s+)?TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${SCHEMA_OPT.source}${ID.source}`,
31
+ 'gi',
32
+ );
33
+ for (const m of normalized.matchAll(createTableRe)) {
34
+ const table = unquote(m[1] ?? m[2] ?? m[3]);
35
+ if (table && table.toUpperCase() !== 'EXISTS') entities.push({ table, operation: 'create_table' });
36
+ }
37
+
38
+ // ALTER TABLE [schema.]name ADD [COLUMN] [IF NOT EXISTS] col
39
+ const addColRe = new RegExp(
40
+ `ALTER\\s+TABLE\\s+(?:ONLY\\s+)?${SCHEMA_OPT.source}${ID.source}\\s+ADD\\s+(?:COLUMN\\s+)?(?:IF\\s+NOT\\s+EXISTS\\s+)?${ID.source}`,
41
+ 'gi',
42
+ );
43
+ for (const m of normalized.matchAll(addColRe)) {
44
+ const table = unquote(m[1] ?? m[2] ?? m[3]);
45
+ const column = unquote(m[4] ?? m[5] ?? m[6]);
46
+ if (!table || !column) continue;
47
+ // Skip non-column ADD operations (CONSTRAINT, INDEX, PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK)
48
+ if (NON_COLUMN_KEYWORDS.has(column.toUpperCase())) continue;
49
+ entities.push({ table, column, operation: 'add_column' });
50
+ }
51
+
52
+ // ALTER TABLE [schema.]name DROP [COLUMN] [IF EXISTS] col
53
+ const dropColRe = new RegExp(
54
+ `ALTER\\s+TABLE\\s+(?:ONLY\\s+)?${SCHEMA_OPT.source}${ID.source}\\s+DROP\\s+(?:COLUMN\\s+)?(?:IF\\s+EXISTS\\s+)?${ID.source}`,
55
+ 'gi',
56
+ );
57
+ for (const m of normalized.matchAll(dropColRe)) {
58
+ const table = unquote(m[1] ?? m[2] ?? m[3]);
59
+ const column = unquote(m[4] ?? m[5] ?? m[6]);
60
+ if (!table || !column) continue;
61
+ // Skip non-column DROP operations (CONSTRAINT, INDEX, PRIMARY KEY, FOREIGN KEY)
62
+ if (NON_COLUMN_KEYWORDS.has(column.toUpperCase())) continue;
63
+ entities.push({ table, column, operation: 'drop_column' });
64
+ }
65
+
66
+ // ALTER TABLE [schema.]name RENAME [COLUMN] old TO new
67
+ const renameColRe = new RegExp(
68
+ `ALTER\\s+TABLE\\s+(?:ONLY\\s+)?${SCHEMA_OPT.source}${ID.source}\\s+RENAME\\s+(?:COLUMN\\s+)?${ID.source}\\s+TO\\s+${ID.source}`,
69
+ 'gi',
70
+ );
71
+ for (const m of normalized.matchAll(renameColRe)) {
72
+ const table = unquote(m[1] ?? m[2] ?? m[3]);
73
+ const oldName = unquote(m[4] ?? m[5] ?? m[6]);
74
+ const column = unquote(m[7] ?? m[8] ?? m[9]);
75
+ if (table && column) entities.push({ table, column, operation: 'rename_column', oldName });
76
+ }
77
+
78
+ // CREATE TYPE [schema.]name
79
+ const createTypeRe = new RegExp(
80
+ `CREATE\\s+TYPE\\s+${SCHEMA_OPT.source}${ID.source}`,
81
+ 'gi',
82
+ );
83
+ for (const m of normalized.matchAll(createTypeRe)) {
84
+ const table = unquote(m[1] ?? m[2] ?? m[3]);
85
+ if (table) entities.push({ table, operation: 'create_type' });
86
+ }
87
+
88
+ // ALTER TYPE [schema.]name ADD VALUE ...
89
+ const alterTypeRe = new RegExp(
90
+ `ALTER\\s+TYPE\\s+${SCHEMA_OPT.source}${ID.source}\\s+ADD\\s+VALUE`,
91
+ 'gi',
92
+ );
93
+ for (const m of normalized.matchAll(alterTypeRe)) {
94
+ const table = unquote(m[1] ?? m[2] ?? m[3]);
95
+ if (table) entities.push({ table, operation: 'create_type' });
96
+ }
97
+
98
+ return entities;
99
+ }
@@ -0,0 +1,91 @@
1
+ // src/core/schema-alignment/llm-check.ts
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import type { ReviewEngine } from '../../adapters/review-engine/types.ts';
5
+ import type { SchemaEntity, LayerScanResult, AlignmentFinding } from './types.ts';
6
+
7
+ const TOTAL_CHAR_BUDGET = 6000;
8
+
9
+ function truncateTop(text: string, maxChars: number): string {
10
+ if (text.length <= maxChars) return text;
11
+ const dropped = text.length - maxChars;
12
+ return `<!-- [schema-alignment: truncated ${dropped} chars] -->\n` + text.slice(dropped);
13
+ }
14
+
15
+ export async function runLlmCheck(
16
+ migrationFiles: string[],
17
+ gapResults: LayerScanResult[],
18
+ engine: ReviewEngine,
19
+ ): Promise<AlignmentFinding[]> {
20
+ let budget = TOTAL_CHAR_BUDGET;
21
+ const migrationSnippets: string[] = [];
22
+
23
+ for (const f of migrationFiles) {
24
+ if (budget <= 0) break;
25
+ let content: string;
26
+ try { content = fs.readFileSync(f, 'utf8'); } catch { continue; }
27
+ const snippet = truncateTop(content, Math.floor(budget * 0.6));
28
+ migrationSnippets.push(`### Migration: ${path.basename(f)}\n\`\`\`sql\n${snippet}\n\`\`\``);
29
+ budget -= snippet.length;
30
+ }
31
+
32
+ const entitySummary = gapResults.map(r => {
33
+ const isDestructive = r.entity.operation === 'drop_column' || r.entity.operation === 'rename_column';
34
+ const gaps = isDestructive
35
+ ? [r.typeLayer, r.apiLayer, r.uiLayer]
36
+ .map((e, i) => e !== null ? (['type', 'api', 'ui'][i]) : null)
37
+ .filter(Boolean).join(', ')
38
+ : [r.typeLayer === null ? 'type' : null, r.apiLayer === null ? 'api' : null, r.uiLayer === null ? 'ui' : null]
39
+ .filter(Boolean).join(', ');
40
+ return `- ${r.entity.operation} ${r.entity.table}${r.entity.column ? '.' + r.entity.column : ''}: ${isDestructive ? 'stale ref in' : 'missing in'} [${gaps}]`;
41
+ }).join('\n');
42
+
43
+ const prompt = [
44
+ 'You are reviewing schema-layer alignment for a software project.',
45
+ '',
46
+ migrationSnippets.length > 0
47
+ ? `The following migration files were changed:\n\n${migrationSnippets.join('\n\n')}`
48
+ : '(no readable migration files)',
49
+ '',
50
+ `The structural scan found these potential alignment gaps:\n${entitySummary || '(none)'}`,
51
+ '',
52
+ 'For each gap, determine if it is a real problem. Return findings as a JSON array:',
53
+ '[{ "table": "name", "column": "name_or_null", "operation": "add_column", "layer": "type", "file": "path/to/relevant/file.ts (optional)", "message": "explanation", "severity": "warning", "confidence": "high" }]',
54
+ 'Return only valid JSON, no prose.',
55
+ ].join('\n');
56
+
57
+ let rawOutput: string;
58
+ try {
59
+ const result = await engine.review({ content: prompt, kind: 'file-batch' });
60
+ rawOutput = result.rawOutput;
61
+ } catch {
62
+ return [];
63
+ }
64
+
65
+ const jsonMatch = rawOutput.match(/\[[\s\S]*\]/);
66
+ if (!jsonMatch) return [];
67
+
68
+ try {
69
+ const parsed = JSON.parse(jsonMatch[0]) as Array<{
70
+ table: string; column?: string; operation: string;
71
+ layer: string; message: string; severity: string; confidence: string;
72
+ file?: string;
73
+ }>;
74
+ return parsed
75
+ .filter(item => item.table && item.layer && item.message)
76
+ .map(item => ({
77
+ entity: {
78
+ table: item.table,
79
+ column: item.column,
80
+ operation: item.operation as SchemaEntity['operation'],
81
+ },
82
+ layer: item.layer as AlignmentFinding['layer'],
83
+ message: item.message,
84
+ file: item.file,
85
+ severity: (item.severity === 'error' ? 'error' : 'warning') as AlignmentFinding['severity'],
86
+ confidence: (['high', 'medium', 'low'].includes(item.confidence) ? item.confidence : 'medium') as AlignmentFinding['confidence'],
87
+ }));
88
+ } catch {
89
+ return [];
90
+ }
91
+ }