@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.
- package/CHANGELOG.md +63 -0
- package/README.md +169 -106
- package/bin/_launcher.js +77 -0
- package/bin/claude-autopilot.js +3 -0
- package/bin/guardrail.js +3 -0
- package/package.json +23 -9
- package/presets/generic/guardrail.config.yaml +35 -0
- package/presets/generic/stack.md +40 -0
- package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
- package/scripts/autoregress.ts +27 -11
- package/skills/autopilot/SKILL.md +170 -0
- package/skills/claude-autopilot.md +80 -0
- package/skills/guardrail.md +39 -0
- package/skills/migrate/SKILL.md +83 -0
- package/src/adapters/council/claude.ts +41 -0
- package/src/adapters/council/openai.ts +40 -0
- package/src/adapters/council/types.ts +7 -0
- package/src/adapters/loader.ts +7 -7
- package/src/adapters/review-engine/auto.ts +2 -2
- package/src/adapters/review-engine/claude.ts +9 -11
- package/src/adapters/review-engine/codex.ts +9 -11
- package/src/adapters/review-engine/gemini.ts +9 -11
- package/src/adapters/review-engine/openai-compatible.ts +10 -12
- package/src/adapters/review-engine/parse-output.ts +32 -6
- package/src/adapters/review-engine/prompt-builder.ts +19 -0
- package/src/adapters/review-engine/types.ts +1 -1
- package/src/adapters/vcs-host/commit-status.ts +39 -0
- package/src/adapters/vcs-host/github.ts +2 -2
- package/src/cli/baseline.ts +125 -0
- package/src/cli/ci.ts +11 -8
- package/src/cli/costs.ts +2 -2
- package/src/cli/council.ts +96 -0
- package/src/cli/detector.ts +21 -5
- package/src/cli/explain.ts +197 -0
- package/src/cli/fix.ts +173 -111
- package/src/cli/hook.ts +72 -27
- package/src/cli/ignore-helper.ts +116 -0
- package/src/cli/index.ts +355 -31
- package/src/cli/init.ts +12 -12
- package/src/cli/lsp.ts +200 -0
- package/src/cli/mcp.ts +206 -0
- package/src/cli/pr-comment.ts +5 -5
- package/src/cli/pr-desc.ts +168 -0
- package/src/cli/pr-review-comments.ts +3 -3
- package/src/cli/pr.ts +76 -0
- package/src/cli/preflight.ts +109 -32
- package/src/cli/report.ts +186 -0
- package/src/cli/run.ts +140 -36
- package/src/cli/scan.ts +233 -0
- package/src/cli/setup.ts +121 -15
- package/src/cli/test-gen.ts +125 -0
- package/src/cli/triage.ts +137 -0
- package/src/cli/watch.ts +52 -31
- package/src/cli/worker.ts +109 -0
- package/src/core/cache/review-cache.ts +2 -2
- package/src/core/chunking/index.ts +2 -2
- package/src/core/config/loader.ts +10 -10
- package/src/core/config/preset-resolver.ts +6 -6
- package/src/core/config/schema.ts +103 -2
- package/src/core/config/types.ts +57 -2
- package/src/core/council/config.ts +71 -0
- package/src/core/council/context.ts +17 -0
- package/src/core/council/runner.ts +83 -0
- package/src/core/council/types.ts +45 -0
- package/src/core/detect/llm-key.ts +89 -0
- package/src/core/detect/workspaces.ts +103 -0
- package/src/core/errors.ts +4 -4
- package/src/core/fix/generator.ts +149 -0
- package/src/core/ignore/index.ts +4 -4
- package/src/core/mcp/concurrency.ts +16 -0
- package/src/core/mcp/handlers/fix-finding.ts +126 -0
- package/src/core/mcp/handlers/get-capabilities.ts +62 -0
- package/src/core/mcp/handlers/get-findings.ts +36 -0
- package/src/core/mcp/handlers/review-diff.ts +65 -0
- package/src/core/mcp/handlers/scan-files.ts +65 -0
- package/src/core/mcp/handlers/validate-fix.ts +41 -0
- package/src/core/mcp/run-store.ts +85 -0
- package/src/core/mcp/workspace.ts +35 -0
- package/src/core/persist/baseline.ts +112 -0
- package/src/core/persist/cost-log.ts +1 -1
- package/src/core/persist/findings-cache.ts +1 -1
- package/src/core/persist/triage.ts +112 -0
- package/src/core/phases/static-rules.ts +18 -5
- package/src/core/pipeline/review-phase.ts +65 -26
- package/src/core/pipeline/run.ts +42 -10
- package/src/core/runtime/lock.ts +2 -2
- package/src/core/runtime/state.ts +2 -2
- package/src/core/schema-alignment/detector.ts +59 -0
- package/src/core/schema-alignment/extractor/index.ts +24 -0
- package/src/core/schema-alignment/extractor/prisma.ts +21 -0
- package/src/core/schema-alignment/extractor/sql.ts +99 -0
- package/src/core/schema-alignment/llm-check.ts +91 -0
- package/src/core/schema-alignment/scanner.ts +107 -0
- package/src/core/schema-alignment/types.ts +43 -0
- package/src/core/shell.ts +3 -3
- package/src/core/static-rules/registry.ts +17 -8
- package/src/core/static-rules/rules/brand-tokens.ts +145 -0
- package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
- package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
- package/src/core/static-rules/rules/missing-auth.ts +70 -0
- package/src/core/static-rules/rules/schema-alignment.ts +132 -0
- package/src/core/static-rules/rules/sql-injection.ts +71 -0
- package/src/core/static-rules/rules/ssrf.ts +63 -0
- package/src/core/static-rules/tailwind-extractor.ts +38 -0
- package/src/core/test-gen/coverage-analyzer.ts +93 -0
- package/src/core/test-gen/framework-detector.ts +21 -0
- package/src/core/test-gen/test-writer.ts +33 -0
- package/src/core/ui/design-context-loader.ts +87 -0
- package/src/core/worker/client.ts +46 -0
- package/src/core/worker/lockfile.ts +38 -0
- package/src/core/worker/server.ts +81 -0
- package/src/formatters/junit.ts +52 -0
- package/src/formatters/sarif.ts +2 -2
- package/src/index.ts +1 -2
- package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
- package/tests/snapshots/index.json +3 -3
- package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
- package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
- package/bin/autopilot.js +0 -20
- package/skills/autopilot.md +0 -157
- /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
- /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
- /package/{src → scripts}/snapshots/serializer.ts +0 -0
package/src/cli/pr.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { runCommand } from './run.ts';
|
|
5
|
+
|
|
6
|
+
const C = {
|
|
7
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
8
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
|
|
9
|
+
};
|
|
10
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
11
|
+
|
|
12
|
+
export interface PrCommandOptions {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
configPath?: string;
|
|
15
|
+
prNumber?: string;
|
|
16
|
+
noPostComments?: boolean;
|
|
17
|
+
noInlineComments?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ghJson<T>(args: string[], cwd: string): T | null {
|
|
21
|
+
const r = spawnSync('gh', args, { cwd, encoding: 'utf8' });
|
|
22
|
+
if (r.status !== 0) return null;
|
|
23
|
+
try { return JSON.parse(r.stdout) as T; } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function gitFetch(remote: string, ref: string, cwd: string): boolean {
|
|
27
|
+
const r = spawnSync('git', ['fetch', remote, ref], { cwd, encoding: 'utf8', stdio: 'pipe' });
|
|
28
|
+
return r.status === 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runPr(options: PrCommandOptions = {}): Promise<number> {
|
|
32
|
+
const cwd = options.cwd ?? process.cwd();
|
|
33
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(configPath)) {
|
|
36
|
+
console.error(fmt('red', `[pr] guardrail.config.yaml not found at ${configPath}`));
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve PR number
|
|
41
|
+
let prNumber = options.prNumber;
|
|
42
|
+
if (!prNumber) {
|
|
43
|
+
const detected = ghJson<{ number: number }>(['pr', 'view', '--json', 'number'], cwd);
|
|
44
|
+
if (!detected) {
|
|
45
|
+
console.error(fmt('red', '[pr] No PR number given and no open PR found for current branch.'));
|
|
46
|
+
console.error(fmt('dim', ' Usage: guardrail pr <number>'));
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
prNumber = String(detected.number);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Look up PR metadata
|
|
53
|
+
interface PrMeta { number: number; baseRefName: string; headRefName: string; title: string }
|
|
54
|
+
const pr = ghJson<PrMeta>(['pr', 'view', prNumber, '--json', 'number,baseRefName,headRefName,title'], cwd);
|
|
55
|
+
if (!pr) {
|
|
56
|
+
console.error(fmt('red', `[pr] Could not fetch PR #${prNumber} — is gh authenticated?`));
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
console.log(`\n${fmt('bold', `[guardrail pr]`)} #${pr.number} ${fmt('dim', pr.title)}`);
|
|
61
|
+
console.log(fmt('dim', ` base: ${pr.baseRefName} head: ${pr.headRefName}`));
|
|
62
|
+
|
|
63
|
+
// Fetch base ref so diff works locally
|
|
64
|
+
const fetched = gitFetch('origin', pr.baseRefName, cwd);
|
|
65
|
+
if (!fetched) {
|
|
66
|
+
console.log(fmt('yellow', ` [pr] Warning: could not fetch origin/${pr.baseRefName} — diff may be stale`));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return runCommand({
|
|
70
|
+
cwd,
|
|
71
|
+
configPath,
|
|
72
|
+
base: `origin/${pr.baseRefName}`,
|
|
73
|
+
postComments: !options.noPostComments,
|
|
74
|
+
inlineComments: !options.noInlineComments,
|
|
75
|
+
});
|
|
76
|
+
}
|
package/src/cli/preflight.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { runSafe } from '../core/shell.ts';
|
|
6
|
+
import { detectLLMKey, loadEnvFile, LLM_KEY_NAMES } from '../core/detect/llm-key.ts';
|
|
6
7
|
|
|
7
8
|
const PASS = '\x1b[32m✓\x1b[0m';
|
|
8
9
|
const FAIL = '\x1b[31m✗\x1b[0m';
|
|
@@ -16,26 +17,92 @@ interface Check {
|
|
|
16
17
|
message?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function loadEnvFile(filePath: string): Record<string, string> {
|
|
20
|
-
const vars: Record<string, string> = {};
|
|
21
|
-
try {
|
|
22
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
23
|
-
for (const line of content.split('\n')) {
|
|
24
|
-
const trimmed = line.trim();
|
|
25
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
26
|
-
const eq = trimmed.indexOf('=');
|
|
27
|
-
if (eq < 0) continue;
|
|
28
|
-
vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
29
|
-
}
|
|
30
|
-
} catch { /* ignore */ }
|
|
31
|
-
return vars;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
20
|
export interface DoctorResult {
|
|
35
21
|
blockers: number;
|
|
36
22
|
warnings: number;
|
|
37
23
|
}
|
|
38
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Checks that the superpowers plugin skills required by the pipeline are resolvable
|
|
27
|
+
* from the usual Claude Code plugin paths. Returns skill names that weren't found.
|
|
28
|
+
*/
|
|
29
|
+
const REQUIRED_SUPERPOWERS_SKILLS = [
|
|
30
|
+
'writing-plans',
|
|
31
|
+
'using-git-worktrees',
|
|
32
|
+
'subagent-driven-development',
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
function skillRoots(): string[] {
|
|
36
|
+
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
37
|
+
const cwd = process.cwd();
|
|
38
|
+
const roots: string[] = [];
|
|
39
|
+
// Project-local plugin install
|
|
40
|
+
roots.push(path.join(cwd, '.claude', 'plugins'));
|
|
41
|
+
// User-global plugin install
|
|
42
|
+
if (home) roots.push(path.join(home, '.claude', 'plugins'));
|
|
43
|
+
return roots.filter(p => fs.existsSync(p));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function findMissingSuperpowersSkills(): string[] {
|
|
47
|
+
// Traverse each root once, collect all discovered skill names, then diff against
|
|
48
|
+
// the required set. Previous implementation did N × roots separate recursive walks.
|
|
49
|
+
const discovered = new Set<string>();
|
|
50
|
+
const MAX_DIRS_PER_ROOT = 2000; // safety cap to prevent pathological plugin trees
|
|
51
|
+
|
|
52
|
+
for (const root of skillRoots()) {
|
|
53
|
+
collectSkills(root, discovered, { visited: { n: 0 }, max: MAX_DIRS_PER_ROOT });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return REQUIRED_SUPERPOWERS_SKILLS.filter(s => !discovered.has(s));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Walks up to 8 levels deep, capped at `max` directories total. When it finds a
|
|
60
|
+
// `skills/` directory, records every `<skill-name>/SKILL.md` and `<skill-name>.md`
|
|
61
|
+
// entry directly into the Set. Never revisits by name (Claude Code plugin caches
|
|
62
|
+
// can contain many parallel copies — we only care whether a skill exists *somewhere*).
|
|
63
|
+
function collectSkills(
|
|
64
|
+
dir: string,
|
|
65
|
+
out: Set<string>,
|
|
66
|
+
ctx: { visited: { n: number }; max: number },
|
|
67
|
+
depth = 0,
|
|
68
|
+
): void {
|
|
69
|
+
if (depth > 8) return;
|
|
70
|
+
if (ctx.visited.n >= ctx.max) return;
|
|
71
|
+
ctx.visited.n++;
|
|
72
|
+
|
|
73
|
+
let entries: fs.Dirent[];
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If this dir has a skills/ child, record every skill inside it
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
if (entry.isDirectory() && entry.name === 'skills') {
|
|
83
|
+
const skillsDir = path.join(dir, 'skills');
|
|
84
|
+
try {
|
|
85
|
+
for (const skill of fs.readdirSync(skillsDir, { withFileTypes: true })) {
|
|
86
|
+
if (skill.isDirectory() && fs.existsSync(path.join(skillsDir, skill.name, 'SKILL.md'))) {
|
|
87
|
+
out.add(skill.name);
|
|
88
|
+
} else if (skill.isFile() && skill.name.endsWith('.md') && skill.name !== 'README.md') {
|
|
89
|
+
out.add(skill.name.slice(0, -3)); // strip .md
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch { /* ignore */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Recurse into non-skills dirs (bounded depth + visit cap prevent pathological scans)
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory()) continue;
|
|
99
|
+
if (entry.name.startsWith('.')) continue;
|
|
100
|
+
if (entry.name === 'node_modules') continue;
|
|
101
|
+
if (entry.name === 'skills') continue; // already handled above
|
|
102
|
+
collectSkills(path.join(dir, entry.name), out, ctx, depth + 1);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
39
106
|
export async function runDoctor(): Promise<DoctorResult> {
|
|
40
107
|
const checks: Check[] = [];
|
|
41
108
|
|
|
@@ -56,7 +123,7 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
56
123
|
checks.push({
|
|
57
124
|
name: 'tsx available',
|
|
58
125
|
result: tsxVersion ? 'pass' : 'fail',
|
|
59
|
-
message: !tsxVersion ? 'tsx not found — run: npm install @delegance/
|
|
126
|
+
message: !tsxVersion ? 'tsx not found — run: npm install @delegance/guardrail (includes tsx)' : undefined,
|
|
60
127
|
});
|
|
61
128
|
|
|
62
129
|
// 3. gh CLI authenticated
|
|
@@ -67,13 +134,13 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
67
134
|
message: ghAuth === null ? 'gh CLI not authenticated — run: gh auth login' : undefined,
|
|
68
135
|
});
|
|
69
136
|
|
|
70
|
-
// 4.
|
|
71
|
-
const configYaml = path.join(process.cwd(), '
|
|
137
|
+
// 4. guardrail.config.yaml in cwd
|
|
138
|
+
const configYaml = path.join(process.cwd(), 'guardrail.config.yaml');
|
|
72
139
|
checks.push({
|
|
73
|
-
name: '
|
|
140
|
+
name: 'guardrail.config.yaml',
|
|
74
141
|
result: fs.existsSync(configYaml) ? 'pass' : 'warn',
|
|
75
142
|
message: !fs.existsSync(configYaml)
|
|
76
|
-
? '
|
|
143
|
+
? 'guardrail.config.yaml not found in current directory — copy from a preset: presets/nextjs-supabase/guardrail.config.yaml'
|
|
77
144
|
: undefined,
|
|
78
145
|
});
|
|
79
146
|
|
|
@@ -83,21 +150,18 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
83
150
|
name: `Local env file (${envFile ?? 'none found'})`,
|
|
84
151
|
result: envFile ? 'pass' : 'warn',
|
|
85
152
|
message: !envFile
|
|
86
|
-
? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with
|
|
153
|
+
? `No env file found. Looked for: ${ENV_CANDIDATES.join(', ')}. Create one with one of: ${LLM_KEY_NAMES.join(', ')}.`
|
|
87
154
|
: undefined,
|
|
88
155
|
});
|
|
89
156
|
|
|
90
|
-
// 6. LLM API key
|
|
157
|
+
// 6. LLM API key — shared detection with setup/scan/run (all 5 providers)
|
|
91
158
|
const envVars = envFile ? loadEnvFile(envFile) : {};
|
|
92
|
-
const
|
|
93
|
-
const hasOpenAI = !!process.env.OPENAI_API_KEY || !!envVars['OPENAI_API_KEY'];
|
|
94
|
-
const hasLLMKey = hasAnthropic || hasOpenAI;
|
|
95
|
-
const llmKeyName = hasAnthropic ? 'ANTHROPIC_API_KEY' : hasOpenAI ? 'OPENAI_API_KEY' : 'none';
|
|
159
|
+
const { hasKey, preferred } = detectLLMKey({ extraEnv: envVars });
|
|
96
160
|
checks.push({
|
|
97
|
-
name: `LLM API key (${
|
|
98
|
-
result:
|
|
99
|
-
message: !
|
|
100
|
-
? `No LLM API key found — set
|
|
161
|
+
name: `LLM API key (${preferred ?? 'none'})`,
|
|
162
|
+
result: hasKey ? 'pass' : 'warn',
|
|
163
|
+
message: !hasKey
|
|
164
|
+
? `No LLM API key found — set one of: ${LLM_KEY_NAMES.join(', ')} to enable review`
|
|
101
165
|
: undefined,
|
|
102
166
|
});
|
|
103
167
|
|
|
@@ -123,9 +187,22 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
123
187
|
: undefined,
|
|
124
188
|
});
|
|
125
189
|
|
|
190
|
+
// 9. Superpowers plugin — required for pipeline phases, optional for review-only use
|
|
191
|
+
const missingSkills = findMissingSuperpowersSkills();
|
|
192
|
+
const allSkillsFound = missingSkills.length === 0;
|
|
193
|
+
checks.push({
|
|
194
|
+
name: `Superpowers plugin${allSkillsFound ? '' : ` (missing: ${missingSkills.join(', ')})`}`,
|
|
195
|
+
// Treat as warn, not fail — users who only run `claude-autopilot run` (review phase)
|
|
196
|
+
// don't need superpowers. Pipeline invocations (`autopilot` skill) will hard-fail at
|
|
197
|
+
// their own entry point.
|
|
198
|
+
result: allSkillsFound ? 'pass' : 'warn',
|
|
199
|
+
message: !allSkillsFound
|
|
200
|
+
? 'Install: `claude plugin install superpowers` (required for pipeline phases — brainstorm/plan/implement)'
|
|
201
|
+
: undefined,
|
|
202
|
+
});
|
|
126
203
|
|
|
127
204
|
// Print results
|
|
128
|
-
console.log('\n\x1b[1m[doctor]
|
|
205
|
+
console.log('\n\x1b[1m[doctor] Guardrail prerequisite check\x1b[0m\n');
|
|
129
206
|
let blockers = 0;
|
|
130
207
|
let warnings = 0;
|
|
131
208
|
for (const check of checks) {
|
|
@@ -140,7 +217,7 @@ export async function runDoctor(): Promise<DoctorResult> {
|
|
|
140
217
|
|
|
141
218
|
console.log('');
|
|
142
219
|
if (blockers > 0) {
|
|
143
|
-
console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx
|
|
220
|
+
console.log(`\x1b[31m[doctor] ${blockers} blocker(s) — fix before running npx guardrail run\x1b[0m\n`);
|
|
144
221
|
} else if (warnings > 0) {
|
|
145
222
|
console.log(`\x1b[33m[doctor] ${warnings} warning(s) — pipeline will run but some steps may be skipped\x1b[0m\n`);
|
|
146
223
|
} else {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { loadCachedFindings } from '../core/persist/findings-cache.ts';
|
|
4
|
+
import { readCostLog } from '../core/persist/cost-log.ts';
|
|
5
|
+
import type { Finding } from '../core/findings/types.ts';
|
|
6
|
+
|
|
7
|
+
const C = {
|
|
8
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
9
|
+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
|
|
10
|
+
};
|
|
11
|
+
const fmt = (c: keyof typeof C, t: string) => `${C[c]}${t}${C.reset}`;
|
|
12
|
+
|
|
13
|
+
export interface ReportCommandOptions {
|
|
14
|
+
cwd?: string;
|
|
15
|
+
output?: string; // file path to write markdown (default: stdout)
|
|
16
|
+
trend?: boolean; // include trend analysis from cost log run history
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function severityOrder(s: Finding['severity']): number {
|
|
20
|
+
return s === 'critical' ? 0 : s === 'warning' ? 1 : 2;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildTrendSection(cwd: string): string {
|
|
24
|
+
const log = readCostLog(cwd);
|
|
25
|
+
if (log.length === 0) return '';
|
|
26
|
+
|
|
27
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
28
|
+
const recent = log.filter(e => new Date(e.timestamp).getTime() >= sevenDaysAgo);
|
|
29
|
+
const totalCost = log.reduce((s, e) => s + e.costUSD, 0);
|
|
30
|
+
const avgFiles = log.reduce((s, e) => s + e.files, 0) / log.length;
|
|
31
|
+
|
|
32
|
+
const lines: string[] = [
|
|
33
|
+
'## 📈 Trend',
|
|
34
|
+
'',
|
|
35
|
+
`| Metric | Value |`,
|
|
36
|
+
`|--------|-------|`,
|
|
37
|
+
`| Runs (7d) | ${recent.length} |`,
|
|
38
|
+
`| Runs (all-time) | ${log.length} |`,
|
|
39
|
+
`| All-time cost | $${totalCost.toFixed(4)} |`,
|
|
40
|
+
`| Avg files/run | ${avgFiles.toFixed(1)} |`,
|
|
41
|
+
'',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if (recent.length > 0) {
|
|
45
|
+
lines.push('### Recent runs', '');
|
|
46
|
+
lines.push('| Date | Files | Cost |');
|
|
47
|
+
lines.push('|------|-------|------|');
|
|
48
|
+
for (const e of recent.slice(-7).reverse()) {
|
|
49
|
+
const d = new Date(e.timestamp).toLocaleDateString();
|
|
50
|
+
lines.push(`| ${d} | ${e.files} | $${e.costUSD.toFixed(4)} |`);
|
|
51
|
+
}
|
|
52
|
+
lines.push('');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildFileBreakdown(findings: Finding[]): string {
|
|
59
|
+
const withFiles = findings.filter(f => f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>');
|
|
60
|
+
if (withFiles.length === 0) return '';
|
|
61
|
+
|
|
62
|
+
const counts = new Map<string, { critical: number; warning: number; note: number; total: number }>();
|
|
63
|
+
for (const f of withFiles) {
|
|
64
|
+
const entry = counts.get(f.file) ?? { critical: 0, warning: 0, note: 0, total: 0 };
|
|
65
|
+
entry[f.severity]++;
|
|
66
|
+
entry.total++;
|
|
67
|
+
counts.set(f.file, entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sorted = [...counts.entries()].sort((a, b) => b[1].total - a[1].total).slice(0, 10);
|
|
71
|
+
if (sorted.length < 2) return ''; // single file — not worth a table
|
|
72
|
+
|
|
73
|
+
const lines = [
|
|
74
|
+
'## 📁 By File',
|
|
75
|
+
'',
|
|
76
|
+
'| File | Critical | Warning | Note | Total |',
|
|
77
|
+
'|------|----------|---------|------|-------|',
|
|
78
|
+
];
|
|
79
|
+
for (const [file, c] of sorted) {
|
|
80
|
+
lines.push(`| \`${file}\` | ${c.critical || '–'} | ${c.warning || '–'} | ${c.note || '–'} | **${c.total}** |`);
|
|
81
|
+
}
|
|
82
|
+
if (counts.size > 10) lines.push(`| *(${counts.size - 10} more files)* | | | | |`);
|
|
83
|
+
lines.push('');
|
|
84
|
+
return lines.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildSourceBreakdown(findings: Finding[]): string {
|
|
88
|
+
const counts = new Map<string, number>();
|
|
89
|
+
for (const f of findings) {
|
|
90
|
+
counts.set(f.source, (counts.get(f.source) ?? 0) + 1);
|
|
91
|
+
}
|
|
92
|
+
if (counts.size < 2) return '';
|
|
93
|
+
|
|
94
|
+
const lines = ['## 🔬 By Source', '', '| Source | Findings |', '|--------|----------|'];
|
|
95
|
+
for (const [source, n] of [...counts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
96
|
+
lines.push(`| ${source} | ${n} |`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
return lines.join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildMarkdown(findings: Finding[], cwd: string, trend: boolean): string {
|
|
103
|
+
const critical = findings.filter(f => f.severity === 'critical');
|
|
104
|
+
const warnings = findings.filter(f => f.severity === 'warning');
|
|
105
|
+
const notes = findings.filter(f => f.severity === 'note');
|
|
106
|
+
const fixable = critical.length + warnings.length;
|
|
107
|
+
|
|
108
|
+
const lines: string[] = [
|
|
109
|
+
'# Guardrail Report',
|
|
110
|
+
'',
|
|
111
|
+
`> Generated ${new Date().toISOString()}`,
|
|
112
|
+
'',
|
|
113
|
+
'## Summary',
|
|
114
|
+
'',
|
|
115
|
+
`| Severity | Count |`,
|
|
116
|
+
`|----------|-------|`,
|
|
117
|
+
`| 🚨 Critical | ${critical.length} |`,
|
|
118
|
+
`| ⚠️ Warning | ${warnings.length} |`,
|
|
119
|
+
`| ℹ️ Note | ${notes.length} |`,
|
|
120
|
+
`| **Total** | **${findings.length}** |`,
|
|
121
|
+
'',
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
if (fixable > 0) {
|
|
125
|
+
lines.push(`> **${fixable} finding${fixable !== 1 ? 's' : ''} can be auto-fixed** — run \`guardrail fix\` to attempt repairs.`, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (trend) {
|
|
129
|
+
const trendSection = buildTrendSection(cwd);
|
|
130
|
+
if (trendSection) lines.push(trendSection);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const fileBreakdown = buildFileBreakdown(findings);
|
|
134
|
+
if (fileBreakdown) lines.push(fileBreakdown);
|
|
135
|
+
|
|
136
|
+
const sourceBreakdown = buildSourceBreakdown(findings);
|
|
137
|
+
if (sourceBreakdown) lines.push(sourceBreakdown);
|
|
138
|
+
|
|
139
|
+
function renderGroup(label: string, icon: string, group: Finding[]) {
|
|
140
|
+
if (group.length === 0) return;
|
|
141
|
+
lines.push(`## ${icon} ${label}`, '');
|
|
142
|
+
for (const f of group) {
|
|
143
|
+
const loc = f.file && f.file !== '<unspecified>' && f.file !== '<pipeline>'
|
|
144
|
+
? `\`${f.file}${f.line ? `:${f.line}` : ''}\``
|
|
145
|
+
: null;
|
|
146
|
+
lines.push(`### ${loc ? `${loc} — ` : ''}${f.message}`);
|
|
147
|
+
if (f.suggestion) lines.push('', `> **Suggestion:** ${f.suggestion}`);
|
|
148
|
+
lines.push('', `*Source: ${f.source} · Rule: ${f.id}*`, '');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
renderGroup('Critical', '🚨', critical);
|
|
153
|
+
renderGroup('Warnings', '⚠️', warnings);
|
|
154
|
+
renderGroup('Notes', 'ℹ️', notes);
|
|
155
|
+
|
|
156
|
+
if (findings.length === 0) {
|
|
157
|
+
lines.push('## ✅ No findings', '', 'No cached findings — run `guardrail run` or `guardrail scan` first.', '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function runReport(options: ReportCommandOptions = {}): Promise<number> {
|
|
164
|
+
const cwd = options.cwd ?? process.cwd();
|
|
165
|
+
const findings = loadCachedFindings(cwd);
|
|
166
|
+
|
|
167
|
+
if (findings.length === 0) {
|
|
168
|
+
console.log(fmt('yellow', '[report] No cached findings — run `guardrail run` or `guardrail scan` first.'));
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sorted = [...findings].sort((a, b) => severityOrder(a.severity) - severityOrder(b.severity));
|
|
173
|
+
const md = buildMarkdown(sorted, cwd, options.trend ?? false);
|
|
174
|
+
|
|
175
|
+
if (options.output) {
|
|
176
|
+
fs.writeFileSync(options.output, md, 'utf8');
|
|
177
|
+
const critical = findings.filter(f => f.severity === 'critical').length;
|
|
178
|
+
const warnings = findings.filter(f => f.severity === 'warning').length;
|
|
179
|
+
console.log(fmt('bold', `[report] Written to ${options.output}`));
|
|
180
|
+
console.log(` ${critical > 0 ? fmt('red', `${critical} critical`) : fmt('green', '0 critical')} ${warnings > 0 ? fmt('yellow', `${warnings} warning${warnings !== 1 ? 's' : ''}`) : fmt('dim', '0 warnings')}`);
|
|
181
|
+
} else {
|
|
182
|
+
process.stdout.write(md + '\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return findings.some(f => f.severity === 'critical') ? 1 : 0;
|
|
186
|
+
}
|