ghagga 3.2.0 → 3.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.
Files changed (37) hide show
  1. package/dist/commands/index-cmd.d.ts +49 -0
  2. package/dist/commands/index-cmd.d.ts.map +1 -0
  3. package/dist/commands/index-cmd.js +367 -0
  4. package/dist/commands/index-cmd.js.map +1 -0
  5. package/dist/commands/index-cmd.test.d.ts +17 -0
  6. package/dist/commands/index-cmd.test.d.ts.map +1 -0
  7. package/dist/commands/index-cmd.test.js +541 -0
  8. package/dist/commands/index-cmd.test.js.map +1 -0
  9. package/dist/commands/indexer-registry.d.ts +93 -0
  10. package/dist/commands/indexer-registry.d.ts.map +1 -0
  11. package/dist/commands/indexer-registry.js +342 -0
  12. package/dist/commands/indexer-registry.js.map +1 -0
  13. package/dist/commands/indexer-registry.test.d.ts +10 -0
  14. package/dist/commands/indexer-registry.test.d.ts.map +1 -0
  15. package/dist/commands/indexer-registry.test.js +538 -0
  16. package/dist/commands/indexer-registry.test.js.map +1 -0
  17. package/dist/commands/review-blast-radius.test.d.ts +13 -0
  18. package/dist/commands/review-blast-radius.test.d.ts.map +1 -0
  19. package/dist/commands/review-blast-radius.test.js +179 -0
  20. package/dist/commands/review-blast-radius.test.js.map +1 -0
  21. package/dist/commands/review.d.ts +112 -1
  22. package/dist/commands/review.d.ts.map +1 -1
  23. package/dist/commands/review.js +264 -2
  24. package/dist/commands/review.js.map +1 -1
  25. package/dist/commands/review.test.js +290 -0
  26. package/dist/commands/review.test.js.map +1 -1
  27. package/dist/commands/triage.d.ts +14 -0
  28. package/dist/commands/triage.d.ts.map +1 -0
  29. package/dist/commands/triage.js +153 -0
  30. package/dist/commands/triage.js.map +1 -0
  31. package/dist/commands/triage.test.d.ts +8 -0
  32. package/dist/commands/triage.test.d.ts.map +1 -0
  33. package/dist/commands/triage.test.js +184 -0
  34. package/dist/commands/triage.test.js.map +1 -0
  35. package/dist/index.js +35 -0
  36. package/dist/index.js.map +1 -1
  37. package/package.json +4 -3
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Unit tests for the blast-radius pure seams extracted from `reviewCommand`
3
+ * (design v2 D9): `resolveBlastRadiusEnabled`, `checkGraphStaleness`,
4
+ * `computeUncoveredLanguages`.
5
+ *
6
+ * `resolveBlastRadiusEnabled` touches the real filesystem (existsSync) — a
7
+ * REAL temp dir (mkdtempSync) is used, NOT a mocked `node:fs`, so the actual
8
+ * repoPath-relative resolution is exercised (CRITICAL-2 regression guard).
9
+ * This file deliberately does NOT `vi.mock('node:fs')` (unlike review.test.ts)
10
+ * to keep that guarantee honest.
11
+ */
12
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { GRAPH_VERSION } from 'ghagga-core';
16
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
+ import { checkGraphStaleness, computeUncoveredLanguages, resolveBlastRadiusEnabled, } from './review.js';
18
+ // ─── resolveBlastRadiusEnabled ───────────────────────────────────
19
+ describe('resolveBlastRadiusEnabled', () => {
20
+ let repoPath;
21
+ let otherCwd;
22
+ beforeEach(() => {
23
+ repoPath = mkdtempSync(join(tmpdir(), 'ghagga-blast-radius-repo-'));
24
+ otherCwd = mkdtempSync(join(tmpdir(), 'ghagga-blast-radius-cwd-'));
25
+ });
26
+ afterEach(() => {
27
+ rmSync(repoPath, { recursive: true, force: true });
28
+ rmSync(otherCwd, { recursive: true, force: true });
29
+ });
30
+ function withGraph() {
31
+ mkdirSync(join(repoPath, '.ghagga'), { recursive: true });
32
+ writeFileSync(join(repoPath, '.ghagga', 'graph.json'), '{}');
33
+ }
34
+ it('auto-enables when .ghagga/graph.json exists under repoPath', () => {
35
+ withGraph();
36
+ const result = resolveBlastRadiusEnabled(repoPath, {}, {});
37
+ expect(result).toBe(true);
38
+ });
39
+ it('stays off when no graph.json exists', () => {
40
+ const result = resolveBlastRadiusEnabled(repoPath, {}, {});
41
+ expect(result).toBe(false);
42
+ });
43
+ it('resolves against repoPath, NOT process.cwd() (CRITICAL-2)', () => {
44
+ withGraph();
45
+ const originalCwd = process.cwd();
46
+ try {
47
+ process.chdir(otherCwd);
48
+ // The graph lives under repoPath, not the (different) current cwd —
49
+ // the v1 bug used resolve('.') and would have returned false here.
50
+ const result = resolveBlastRadiusEnabled(repoPath, {}, {});
51
+ expect(result).toBe(true);
52
+ }
53
+ finally {
54
+ process.chdir(originalCwd);
55
+ }
56
+ });
57
+ it('--no-blast-radius (blastRadius: false) wins over an existing graph', () => {
58
+ withGraph();
59
+ const result = resolveBlastRadiusEnabled(repoPath, { blastRadius: false }, {});
60
+ expect(result).toBe(false);
61
+ });
62
+ it('.ghagga.json enableBlastRadius: false wins over an existing graph', () => {
63
+ withGraph();
64
+ const result = resolveBlastRadiusEnabled(repoPath, {}, { enableBlastRadius: false });
65
+ expect(result).toBe(false);
66
+ });
67
+ it('.ghagga.json enableBlastRadius: true wins even without a graph', () => {
68
+ const result = resolveBlastRadiusEnabled(repoPath, {}, { enableBlastRadius: true });
69
+ expect(result).toBe(true);
70
+ });
71
+ it('explicit --no-blast-radius overrides .ghagga.json enableBlastRadius: true', () => {
72
+ const result = resolveBlastRadiusEnabled(repoPath, { blastRadius: false }, { enableBlastRadius: true });
73
+ expect(result).toBe(false);
74
+ });
75
+ });
76
+ // ─── checkGraphStaleness ──────────────────────────────────────────
77
+ describe('checkGraphStaleness', () => {
78
+ function makeMetadata(overrides = {}) {
79
+ return {
80
+ lastIndexedCommit: 'abc123',
81
+ lastIndexedAt: new Date().toISOString(),
82
+ schemaVersion: GRAPH_VERSION,
83
+ fileCount: 1,
84
+ languages: ['typescript'],
85
+ indexDurationMs: 10,
86
+ ...overrides,
87
+ };
88
+ }
89
+ it('returns no warnings when metadata is fresh and HEAD matches', () => {
90
+ const metadata = makeMetadata({ lastIndexedCommit: 'abc123' });
91
+ expect(checkGraphStaleness(metadata, 'abc123')).toEqual([]);
92
+ });
93
+ it('warns on HEAD mismatch', () => {
94
+ const metadata = makeMetadata({ lastIndexedCommit: 'abc123' });
95
+ const warnings = checkGraphStaleness(metadata, 'def456');
96
+ expect(warnings.some((w) => w.kind === 'head-mismatch')).toBe(true);
97
+ });
98
+ it('skips the HEAD-mismatch check when currentHead is empty (non-git/detached)', () => {
99
+ const metadata = makeMetadata({ lastIndexedCommit: 'abc123' });
100
+ const warnings = checkGraphStaleness(metadata, '');
101
+ expect(warnings.some((w) => w.kind === 'head-mismatch')).toBe(false);
102
+ });
103
+ it('warns on age > 7 days', () => {
104
+ const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
105
+ const metadata = makeMetadata({ lastIndexedCommit: 'abc123', lastIndexedAt: thirtyDaysAgo });
106
+ const warnings = checkGraphStaleness(metadata, 'abc123');
107
+ expect(warnings.some((w) => w.kind === 'stale-age')).toBe(true);
108
+ });
109
+ it('warns distinctly when metadata is null (B-005)', () => {
110
+ const warnings = checkGraphStaleness(null, 'abc123');
111
+ expect(warnings).toHaveLength(1);
112
+ expect(warnings[0]?.kind).toBe('no-metadata');
113
+ });
114
+ it('warns on graphVersion mismatch (B-003)', () => {
115
+ const metadata = makeMetadata({ lastIndexedCommit: 'abc123', graphVersion: GRAPH_VERSION + 1 });
116
+ const warnings = checkGraphStaleness(metadata, 'abc123');
117
+ expect(warnings.some((w) => w.kind === 'version-mismatch')).toBe(true);
118
+ });
119
+ it('does not warn on graphVersion match', () => {
120
+ const metadata = makeMetadata({ lastIndexedCommit: 'abc123', graphVersion: GRAPH_VERSION });
121
+ const warnings = checkGraphStaleness(metadata, 'abc123');
122
+ expect(warnings.some((w) => w.kind === 'version-mismatch')).toBe(false);
123
+ });
124
+ });
125
+ // ─── computeUncoveredLanguages ────────────────────────────────────
126
+ describe('computeUncoveredLanguages', () => {
127
+ function makeGraph(nodeLanguages) {
128
+ return {
129
+ version: GRAPH_VERSION,
130
+ rootDir: '.',
131
+ nodes: Object.fromEntries(Object.entries(nodeLanguages).map(([path, language]) => [
132
+ path,
133
+ {
134
+ hash: 'h',
135
+ // biome-ignore lint/suspicious/noExplicitAny: fixture language is dynamic
136
+ language: language,
137
+ imports: [],
138
+ exports: [],
139
+ calls: [],
140
+ isTest: false,
141
+ },
142
+ ])),
143
+ };
144
+ }
145
+ it('flags a changed file whose language has zero graph coverage', () => {
146
+ const graph = makeGraph({ 'src/main.go': 'go' });
147
+ const uncovered = computeUncoveredLanguages(graph, ['app.py']);
148
+ expect(uncovered).toContain('python');
149
+ });
150
+ it('does not flag a changed file whose language IS covered', () => {
151
+ const graph = makeGraph({ 'src/main.go': 'go' });
152
+ const uncovered = computeUncoveredLanguages(graph, ['src/other.go']);
153
+ expect(uncovered).not.toContain('go');
154
+ });
155
+ it('regex-fallback graph (languages populated from real nodes) is NOT all-flagged (CRITICAL-1)', () => {
156
+ // A regex-fallback-built graph still has real per-file `language` on
157
+ // every node (unlike dispatch's `indexedLanguages`, which is empty for
158
+ // the regex path) — so coverage must be computed from graph CONTENTS.
159
+ const graph = makeGraph({ 'src/a.ts': 'typescript', 'src/b.py': 'python' });
160
+ const uncovered = computeUncoveredLanguages(graph, ['src/a.ts', 'src/b.py']);
161
+ expect(uncovered).toEqual([]);
162
+ });
163
+ it('flags a subpackage-only language not present anywhere in the graph', () => {
164
+ const graph = makeGraph({ 'src/main.go': 'go' });
165
+ const uncovered = computeUncoveredLanguages(graph, [
166
+ 'packages/api/requirements.txt',
167
+ 'src/main.go',
168
+ ]);
169
+ // requirements.txt has no detectable language extension — must not crash
170
+ // and must not be spuriously flagged as a language.
171
+ expect(uncovered).toEqual([]);
172
+ });
173
+ it('flags multiple distinct uncovered languages without duplicates', () => {
174
+ const graph = makeGraph({ 'src/main.go': 'go' });
175
+ const uncovered = computeUncoveredLanguages(graph, ['a.py', 'b.py', 'c.rs']);
176
+ expect(uncovered.sort()).toEqual(['python', 'rust']);
177
+ });
178
+ });
179
+ //# sourceMappingURL=review-blast-radius.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"review-blast-radius.test.js","sourceRoot":"","sources":["../../src/commands/review-blast-radius.test.ts"],"names":[],"mappings":"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"}
@@ -5,7 +5,7 @@
5
5
  * environment, and optional .ghagga.json file, then runs the
6
6
  * core review pipeline and formats the output.
7
7
  */
8
- import type { LLMProvider, ReviewMode } from 'ghagga-core';
8
+ import type { DependencyGraph, GraphMetadata, LLMProvider, ReviewMode } from 'ghagga-core';
9
9
  export interface ReviewOptions {
10
10
  mode: ReviewMode;
11
11
  provider: LLMProvider;
@@ -53,6 +53,117 @@ export interface ReviewOptions {
53
53
  lenses?: string;
54
54
  /** Path to custom lens definitions directory (from --lens-dir flag). */
55
55
  lensDir?: string;
56
+ /**
57
+ * Explicit blast-radius override from `--no-blast-radius`. `false` forces
58
+ * blast-radius off regardless of graph presence / config; `undefined`
59
+ * defers to `.ghagga.json`/auto-detection (see `resolveBlastRadiusEnabled`).
60
+ */
61
+ blastRadius?: boolean;
62
+ }
63
+ /**
64
+ * A single entry in the `.ghagga.json` `providerChain` array — routes ONE
65
+ * multi-voice reviewer (consensus vote / fan-out lens) to a specific
66
+ * cross-engine target via mcp-llm-bridge. Mirrors `ghagga-core`'s
67
+ * `ProviderChainEntry`, minus `apiKey`: the gateway token is resolved at
68
+ * runtime (CLI `--api-key` / stored login token), never stored in plaintext
69
+ * in the config file.
70
+ */
71
+ interface GhaggaConfigProviderChainEntry {
72
+ /** Only 'gateway' entries are meaningful for cross-engine routing. */
73
+ provider: 'gateway';
74
+ /** Model identifier passed to the bridge (e.g. "claude-sonnet-4-6"). */
75
+ model: string;
76
+ /**
77
+ * Bridge-side provider id to route to (e.g. 'cli-claude', 'codex-cli').
78
+ * Short-circuits the bridge's model-based routing.
79
+ */
80
+ targetProvider?: string;
81
+ /** Gateway base URL. Defaults to the first entry's / flat gateway config when omitted. */
82
+ gatewayUrl?: string;
83
+ /** OpenCode model in `provider/model` format (cli-bridge only — unused for gateway). */
84
+ cliModel?: string;
85
+ }
86
+ interface GhaggaConfig {
87
+ mode?: string;
88
+ provider?: string;
89
+ model?: string;
90
+ enableSemgrep?: boolean;
91
+ enableTrivy?: boolean;
92
+ enableCpd?: boolean;
93
+ customRules?: string[];
94
+ ignorePatterns?: string[];
95
+ reviewLevel?: string;
96
+ disabledTools?: string[];
97
+ enabledTools?: string[];
98
+ lenses?: string[];
99
+ /**
100
+ * Per-voice cross-engine provider chain (BL-CLI-PROVIDER-CHAIN). When set,
101
+ * consensus (3-vote) and fan-out (N-lens) modes round-robin each voice
102
+ * across these gateway entries instead of hammering a single engine.
103
+ */
104
+ providerChain?: GhaggaConfigProviderChainEntry[];
105
+ /**
106
+ * Enable blast-radius filtering (design v2 D7). `true`/`false` override
107
+ * auto-detection (`.ghagga/graph.json` presence); omit to auto-detect.
108
+ * `--no-blast-radius` (ReviewOptions.blastRadius === false) always wins
109
+ * over this config key.
110
+ */
111
+ enableBlastRadius?: boolean;
56
112
  }
57
113
  export declare function reviewCommand(targetPath: string, options: ReviewOptions): Promise<void>;
114
+ /** One staleness/coverage signal for `checkGraphStaleness` — WARN, never block. */
115
+ export interface StalenessWarning {
116
+ kind: 'head-mismatch' | 'stale-age' | 'no-metadata' | 'version-mismatch';
117
+ message: string;
118
+ }
119
+ /**
120
+ * Resolve whether blast-radius should be enabled for this review run.
121
+ *
122
+ * Precedence (highest to lowest):
123
+ * 1. `--no-blast-radius` (options.blastRadius === false) → OFF
124
+ * 2. `.ghagga.json` `enableBlastRadius` (explicit true/false) → that value
125
+ * 3. Auto-ON when `<repoPath>/.ghagga/graph.json` exists
126
+ * 4. OFF (no graph, no override)
127
+ *
128
+ * CRITICAL-2 (JD B-001): the existence check is resolved against `repoPath`
129
+ * — the review target — NOT `process.cwd()`, so `ghagga review <other-dir>`
130
+ * from an unrelated cwd still auto-detects correctly.
131
+ */
132
+ export declare function resolveBlastRadiusEnabled(repoPath: string, options: Pick<ReviewOptions, 'blastRadius'>, fileConfig: Pick<GhaggaConfig, 'enableBlastRadius'>): boolean;
133
+ /**
134
+ * Detect staleness/coverage-verification signals for the loaded graph.
135
+ * ALWAYS warn-and-continue (design v2 D2) — never blocks the review.
136
+ *
137
+ * - HEAD mismatch: `metadata.lastIndexedCommit` non-empty and differs from
138
+ * `currentHead`. An EMPTY `currentHead` (non-git/detached checkout) skips
139
+ * this specific check — there's nothing meaningful to compare against.
140
+ * - Age: delegates to `isGraphStale` (>`GRAPH_STALE_DAYS` days).
141
+ * - Metadata absent (B-005): `metadata === null` — graph.json loaded but no
142
+ * sibling metadata.json (e.g. pre-feature `ghagga index` run). Returns
143
+ * EARLY with only this warning — there's nothing else to check.
144
+ * - Version mismatch (B-003): `metadata.graphVersion` set and differs from
145
+ * the current `GRAPH_VERSION` (equivalent to comparing against the loaded
146
+ * graph's `version`, which `validateGraph` already pins to `GRAPH_VERSION`).
147
+ */
148
+ export declare function checkGraphStaleness(metadata: GraphMetadata | null, currentHead: string): StalenessWarning[];
149
+ /**
150
+ * Compute the set of languages present among `changedFiles` that have ZERO
151
+ * coverage in the loaded graph — i.e. files whose blast-radius dependent
152
+ * count is unreliable (shows zero, not "no dependents").
153
+ *
154
+ * Coverage is derived from the graph's ACTUAL node contents (design v2 D8,
155
+ * JD CRITICAL-1), not from indexer-dispatch bookkeeping:
156
+ * - The regex-fallback path sets `indexedLanguages: []` even though every
157
+ * node has a real `language` — keying off dispatch state would flag
158
+ * every changed file as uncovered.
159
+ * - `detectPresentLanguages` is root-only — a language that only exists in
160
+ * a subpackage (e.g. `packages/api/requirements.txt`) is never detected
161
+ * or skipped, so dispatch state gives no signal at all.
162
+ *
163
+ * Files whose language cannot be detected (`detectLanguage` returns
164
+ * `undefined` — unknown extension) are silently excluded; there is no
165
+ * language identity to report as uncovered.
166
+ */
167
+ export declare function computeUncoveredLanguages(graph: DependencyGraph, changedFiles: string[]): string[];
168
+ export {};
58
169
  //# sourceMappingURL=review.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"review.d.ts","sourceRoot":"","sources":["../../src/commands/review.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH,OAAO,KAAK,EACV,WAAW,EAIX,UAAU,EAKX,MAAM,aAAa,CAAC;AA6CrB,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE,WAAW,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,UAAU,CAAC;IAC7C,wCAAwC;IACxC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,EAAE,OAAO,CAAC;IACb,MAAM,EAAE,OAAO,CAAC;IAChB,qDAAqD;IACrD,aAAa,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,OAAO,CAAC;IAEjB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,uCAAuC;IACvC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,8DAA8D;IAC9D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IAErB,yDAAyD;IACzD,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,uDAAuD;IACvD,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,yCAAyC;IACzC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAqBD,wBAAsB,aAAa,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAiQ7F"}
1
+ {"version":3,"file":"review.d.ts","sourceRoot":"","sources":["../../src/commands/review.ts"],"names":[],"mappings":"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"}
@@ -7,8 +7,9 @@
7
7
  */
8
8
  import { execSync } from 'node:child_process';
9
9
  import { existsSync, readFileSync } from 'node:fs';
10
+ import { readFile } from 'node:fs/promises';
10
11
  import { join, resolve } from 'node:path';
11
- import { buildSarif, DEFAULT_SETTINGS, EngramMemoryStorage, formatReviewComment, initializeDefaultTools, REVIEW_COMMENT_MARKER, reviewPipeline, SqliteMemoryStorage, toolRegistry, } from 'ghagga-core';
12
+ import { buildSarif, DEFAULT_SETTINGS, detectLanguage, EngramMemoryStorage, FilesystemGraphLoader, formatReviewComment, GRAPH_VERSION, initializeDefaultTools, isGraphStale, NullGraphLoader, PreloadedGraphLoader, parseDiffFiles, REVIEW_COMMENT_MARKER, reviewPipeline, SqliteMemoryStorage, toolRegistry, } from 'ghagga-core';
12
13
  import { GitHubForgeAdapter, GitLabForgeAdapter, StaticTokenProvider, } from 'ghagga-forge';
13
14
  import { createCliGitHubClientPort, resolveGitHubRepoId } from '../lib/cli-github-client-port.js';
14
15
  import { createCliGitLabClientPort, resolveGitLabApiBase, resolveGitLabProjectId, } from '../lib/cli-gitlab-client-port.js';
@@ -94,6 +95,89 @@ export async function reviewCommand(targetPath, options) {
94
95
  const fileConfig = loadConfigFile(repoPath, options.config);
95
96
  // Step 3: Merge settings (CLI options take priority over config file)
96
97
  const settings = mergeSettings(options, fileConfig);
98
+ // Step 3.4: Resolve blast-radius auto-ON (design v2 D7) — resolved
99
+ // against repoPath (the review target), not process.cwd() (CRITICAL-2).
100
+ settings.enableBlastRadius = resolveBlastRadiusEnabled(repoPath, options, fileConfig);
101
+ // Step 3.4.5: Wire the local FilesystemGraphLoader + fs fileReader when
102
+ // blast-radius is enabled (design v2 D3/D4), and surface staleness
103
+ // (D2) + partial-language-coverage (D8) as advisory warnings — never
104
+ // blocking. Graph-absent is a silent no-op: applyBlastRadius itself
105
+ // already emits its own "no graph available" pipeline event.
106
+ //
107
+ // The graph is loaded exactly ONCE here (for staleness/coverage). The
108
+ // already-loaded result is then wrapped in a PreloadedGraphLoader (or
109
+ // NullGraphLoader when absent/malformed/oversize) so the pipeline's own
110
+ // `applyBlastRadius` step — which unconditionally calls
111
+ // `graphLoader.load()` — reads it from memory instead of re-reading
112
+ // graph.json (up to 20MB) and re-emitting the oversize/malformed warning
113
+ // a second time (R4-001).
114
+ let graphLoader;
115
+ let fileReader;
116
+ // Populated below (reusing the SAME `git rev-parse HEAD` already
117
+ // computed for staleness checking) and threaded into `ReviewInput` as
118
+ // `currentHead` — the Pillar 0 freshness precondition for
119
+ // symbol-precise blast-radius narrowing (scip-symbol-exclusion D0).
120
+ let currentHead;
121
+ if (settings.enableBlastRadius) {
122
+ const filesystemGraphLoader = new FilesystemGraphLoader(repoPath, {
123
+ onOversize: (bytes, max) => {
124
+ tui.log.warn(`⚠️ Dependency graph exceeds ${max} bytes (actual: ${bytes}) — ignoring, blast-radius disabled for this run.`);
125
+ },
126
+ onMalformed: (reason) => {
127
+ tui.log.warn(`⚠️ Dependency graph is stale/corrupt — ignoring (${reason}).`);
128
+ },
129
+ });
130
+ fileReader = async (filePath) => {
131
+ try {
132
+ return await readFile(join(repoPath, filePath), 'utf-8');
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ };
138
+ try {
139
+ const graph = await filesystemGraphLoader.load();
140
+ if (graph) {
141
+ const metadata = await filesystemGraphLoader.loadMetadata();
142
+ try {
143
+ currentHead = execSync('git rev-parse HEAD', {
144
+ cwd: repoPath,
145
+ encoding: 'utf-8',
146
+ }).trim();
147
+ }
148
+ catch {
149
+ currentHead = '';
150
+ }
151
+ for (const warning of checkGraphStaleness(metadata, currentHead)) {
152
+ tui.log.warn(`⚠️ ${warning.message}`);
153
+ }
154
+ const changedFiles = parseDiffFiles(diff).map((f) => f.path);
155
+ const uncoveredLanguages = computeUncoveredLanguages(graph, changedFiles);
156
+ if (uncoveredLanguages.length > 0) {
157
+ tui.log.warn(`⚠️ Partial graph coverage: no indexed dependents for ${uncoveredLanguages.join(', ')} — ` +
158
+ 'blast-radius will show 0 dependents for these files, which may be unreliable rather than accurate.');
159
+ }
160
+ // Hand the pipeline the graph we already loaded — no re-read.
161
+ graphLoader = new PreloadedGraphLoader(graph, metadata);
162
+ }
163
+ else {
164
+ // Absent/malformed/oversize — filesystemGraphLoader already fired
165
+ // its onOversize/onMalformed warning exactly once above. Hand the
166
+ // pipeline a loader that always resolves to null so it degrades
167
+ // without touching the filesystem again.
168
+ graphLoader = new NullGraphLoader();
169
+ }
170
+ }
171
+ catch (error) {
172
+ const message = error instanceof Error ? error.message : String(error);
173
+ tui.log.warn(`⚠️ Blast-radius staleness/coverage check failed (continuing): ${message}`);
174
+ graphLoader = new NullGraphLoader();
175
+ }
176
+ }
177
+ // Step 3.5: Build the per-voice cross-engine provider chain, if configured
178
+ // (BL-CLI-PROVIDER-CHAIN). Undefined when absent — regression-safe, the
179
+ // pipeline falls back to the flat provider/model exactly as before.
180
+ const providerChain = buildProviderChain(fileConfig, options.apiKey);
97
181
  // Step 4: Show progress
98
182
  if (!options.outputFormat) {
99
183
  tui.intro('🤖 GHAGGA Code Review');
@@ -197,6 +281,18 @@ export async function reviewCommand(targetPath, options) {
197
281
  enhance: options.enhance,
198
282
  // --quick: disable AI review, use static analysis only
199
283
  ...(options.quick ? { aiReviewEnabled: false } : {}),
284
+ // Per-voice cross-engine routing (BL-CLI-PROVIDER-CHAIN) — undefined
285
+ // when `.ghagga.json` has no providerChain, unchanged flat-provider path.
286
+ ...(providerChain ? { providerChain } : {}),
287
+ // Blast-radius (design v2) — undefined when disabled/no graph, matching
288
+ // the pre-existing `applyBlastRadius` gate (`enableBlastRadius && graphLoader`).
289
+ ...(graphLoader ? { graphLoader } : {}),
290
+ ...(fileReader ? { fileReader } : {}),
291
+ // Pillar 0 freshness precondition (scip-symbol-exclusion D0) — reuses
292
+ // the SAME `git rev-parse HEAD` already computed above for staleness
293
+ // checking. Undefined when blast-radius is disabled or HEAD couldn't
294
+ // be resolved (non-git repo) — narrowing degrades to a no-op.
295
+ ...(currentHead ? { currentHead } : {}),
200
296
  });
201
297
  // Step 5.5: Persist memory to disk
202
298
  await memoryStorage?.close();
@@ -544,6 +640,39 @@ function getGitDiff(repoPath) {
544
640
  }
545
641
  }
546
642
  // ─── Config File ────────────────────────────────────────────────
643
+ /**
644
+ * Validate the shape of a `.ghagga.json` `providerChain` array. Throws a
645
+ * descriptive `Error` on the first invalid entry — provider misrouting is
646
+ * high-stakes (silently reviewing with the wrong engine), so this fails
647
+ * loudly rather than the soft-warn-and-continue behavior used for a
648
+ * generally malformed config file.
649
+ */
650
+ function validateProviderChain(chain) {
651
+ if (!Array.isArray(chain)) {
652
+ throw new Error('providerChain must be an array');
653
+ }
654
+ chain.forEach((rawEntry, index) => {
655
+ if (typeof rawEntry !== 'object' || rawEntry === null) {
656
+ throw new Error(`providerChain[${index}] must be an object`);
657
+ }
658
+ const entry = rawEntry;
659
+ if (entry.provider !== 'gateway') {
660
+ throw new Error(`providerChain[${index}].provider must be "gateway" (got ${JSON.stringify(entry.provider)})`);
661
+ }
662
+ if (typeof entry.model !== 'string' || entry.model.length === 0) {
663
+ throw new Error(`providerChain[${index}].model must be a non-empty string`);
664
+ }
665
+ if (entry.targetProvider !== undefined && typeof entry.targetProvider !== 'string') {
666
+ throw new Error(`providerChain[${index}].targetProvider must be a string`);
667
+ }
668
+ if (entry.gatewayUrl !== undefined && typeof entry.gatewayUrl !== 'string') {
669
+ throw new Error(`providerChain[${index}].gatewayUrl must be a string`);
670
+ }
671
+ if (entry.cliModel !== undefined && typeof entry.cliModel !== 'string') {
672
+ throw new Error(`providerChain[${index}].cliModel must be a string`);
673
+ }
674
+ });
675
+ }
547
676
  /**
548
677
  * Load and parse an optional .ghagga.json config file.
549
678
  */
@@ -552,15 +681,148 @@ function loadConfigFile(repoPath, configPath) {
552
681
  if (!existsSync(filePath)) {
553
682
  return {};
554
683
  }
684
+ let parsed;
555
685
  try {
556
686
  const raw = readFileSync(filePath, 'utf-8');
557
- return JSON.parse(raw);
687
+ parsed = JSON.parse(raw);
558
688
  }
559
689
  catch (error) {
560
690
  const message = error instanceof Error ? error.message : String(error);
561
691
  tui.log.warn(`⚠️ Could not parse config file: ${message}`);
562
692
  return {};
563
693
  }
694
+ if (parsed.providerChain !== undefined) {
695
+ try {
696
+ validateProviderChain(parsed.providerChain);
697
+ }
698
+ catch (error) {
699
+ const message = error instanceof Error ? error.message : String(error);
700
+ tui.log.error(`❌ Invalid .ghagga.json providerChain: ${message}`);
701
+ process.exit(1);
702
+ return {};
703
+ }
704
+ }
705
+ return parsed;
706
+ }
707
+ /**
708
+ * Map a validated `.ghagga.json` providerChain into `ghagga-core`'s
709
+ * `ProviderChainEntry[]` for `ReviewInput.providerChain` — the piece that
710
+ * closes the CLI's gap in per-voice cross-engine routing (consensus/fan-out
711
+ * modes round-robin each voice across these gateway entries, see
712
+ * `resolveGenerateTextFns` in `packages/core/src/pipeline/providers.ts`).
713
+ *
714
+ * `apiKey` is NEVER read from the config file (never stored in plaintext) —
715
+ * it is filled at runtime from the resolved gateway token (`options.apiKey`,
716
+ * itself CLI flag > env var > stored login token, see `apps/cli/src/index.ts`).
717
+ */
718
+ function buildProviderChain(fileConfig, apiKey) {
719
+ if (!fileConfig.providerChain || fileConfig.providerChain.length === 0) {
720
+ return undefined;
721
+ }
722
+ return fileConfig.providerChain.map((entry) => ({
723
+ provider: entry.provider,
724
+ model: entry.model,
725
+ apiKey: apiKey ?? '',
726
+ ...(entry.targetProvider !== undefined ? { targetProvider: entry.targetProvider } : {}),
727
+ ...(entry.gatewayUrl !== undefined ? { gatewayUrl: entry.gatewayUrl } : {}),
728
+ ...(entry.cliModel !== undefined ? { cliModel: entry.cliModel } : {}),
729
+ }));
730
+ }
731
+ /**
732
+ * Resolve whether blast-radius should be enabled for this review run.
733
+ *
734
+ * Precedence (highest to lowest):
735
+ * 1. `--no-blast-radius` (options.blastRadius === false) → OFF
736
+ * 2. `.ghagga.json` `enableBlastRadius` (explicit true/false) → that value
737
+ * 3. Auto-ON when `<repoPath>/.ghagga/graph.json` exists
738
+ * 4. OFF (no graph, no override)
739
+ *
740
+ * CRITICAL-2 (JD B-001): the existence check is resolved against `repoPath`
741
+ * — the review target — NOT `process.cwd()`, so `ghagga review <other-dir>`
742
+ * from an unrelated cwd still auto-detects correctly.
743
+ */
744
+ export function resolveBlastRadiusEnabled(repoPath, options, fileConfig) {
745
+ if (options.blastRadius === false)
746
+ return false;
747
+ if (fileConfig.enableBlastRadius === false)
748
+ return false;
749
+ if (fileConfig.enableBlastRadius === true)
750
+ return true;
751
+ return existsSync(join(repoPath, '.ghagga', 'graph.json'));
752
+ }
753
+ /**
754
+ * Detect staleness/coverage-verification signals for the loaded graph.
755
+ * ALWAYS warn-and-continue (design v2 D2) — never blocks the review.
756
+ *
757
+ * - HEAD mismatch: `metadata.lastIndexedCommit` non-empty and differs from
758
+ * `currentHead`. An EMPTY `currentHead` (non-git/detached checkout) skips
759
+ * this specific check — there's nothing meaningful to compare against.
760
+ * - Age: delegates to `isGraphStale` (>`GRAPH_STALE_DAYS` days).
761
+ * - Metadata absent (B-005): `metadata === null` — graph.json loaded but no
762
+ * sibling metadata.json (e.g. pre-feature `ghagga index` run). Returns
763
+ * EARLY with only this warning — there's nothing else to check.
764
+ * - Version mismatch (B-003): `metadata.graphVersion` set and differs from
765
+ * the current `GRAPH_VERSION` (equivalent to comparing against the loaded
766
+ * graph's `version`, which `validateGraph` already pins to `GRAPH_VERSION`).
767
+ */
768
+ export function checkGraphStaleness(metadata, currentHead) {
769
+ if (!metadata) {
770
+ return [
771
+ {
772
+ kind: 'no-metadata',
773
+ message: 'Graph loaded but no metadata found — staleness cannot be verified; re-run `ghagga index`.',
774
+ },
775
+ ];
776
+ }
777
+ const warnings = [];
778
+ if (currentHead && metadata.lastIndexedCommit && metadata.lastIndexedCommit !== currentHead) {
779
+ warnings.push({
780
+ kind: 'head-mismatch',
781
+ message: `Dependency graph indexed at ${metadata.lastIndexedCommit}, HEAD is ${currentHead} — run \`ghagga index\` to refresh.`,
782
+ });
783
+ }
784
+ if (isGraphStale(metadata)) {
785
+ warnings.push({
786
+ kind: 'stale-age',
787
+ message: `Dependency graph is stale (last indexed: ${metadata.lastIndexedAt}) — run \`ghagga index\` to refresh.`,
788
+ });
789
+ }
790
+ if (metadata.graphVersion !== undefined && metadata.graphVersion !== GRAPH_VERSION) {
791
+ warnings.push({
792
+ kind: 'version-mismatch',
793
+ message: `Graph metadata schema version (${metadata.graphVersion}) does not match the current graph version (${GRAPH_VERSION}) — run \`ghagga index\` to refresh.`,
794
+ });
795
+ }
796
+ return warnings;
797
+ }
798
+ /**
799
+ * Compute the set of languages present among `changedFiles` that have ZERO
800
+ * coverage in the loaded graph — i.e. files whose blast-radius dependent
801
+ * count is unreliable (shows zero, not "no dependents").
802
+ *
803
+ * Coverage is derived from the graph's ACTUAL node contents (design v2 D8,
804
+ * JD CRITICAL-1), not from indexer-dispatch bookkeeping:
805
+ * - The regex-fallback path sets `indexedLanguages: []` even though every
806
+ * node has a real `language` — keying off dispatch state would flag
807
+ * every changed file as uncovered.
808
+ * - `detectPresentLanguages` is root-only — a language that only exists in
809
+ * a subpackage (e.g. `packages/api/requirements.txt`) is never detected
810
+ * or skipped, so dispatch state gives no signal at all.
811
+ *
812
+ * Files whose language cannot be detected (`detectLanguage` returns
813
+ * `undefined` — unknown extension) are silently excluded; there is no
814
+ * language identity to report as uncovered.
815
+ */
816
+ export function computeUncoveredLanguages(graph, changedFiles) {
817
+ const covered = new Set(Object.values(graph.nodes).map((node) => node.language));
818
+ const uncovered = new Set();
819
+ for (const filePath of changedFiles) {
820
+ const language = detectLanguage(filePath);
821
+ if (language && !covered.has(language)) {
822
+ uncovered.add(language);
823
+ }
824
+ }
825
+ return [...uncovered];
564
826
  }
565
827
  // ─── Settings Merge ─────────────────────────────────────────────
566
828
  /**