diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 8fbe40feb..09ca4682b 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -84,6 +84,7 @@ const KEY_SPECS: Record = { skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' }, skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' }, skipSkills: { target: 'skipSkills', kind: 'boolean' }, + pdg: { target: 'pdg', kind: 'boolean' }, indexOnly: { target: 'indexOnly', kind: 'boolean' }, stats: { target: 'stats', kind: 'boolean' }, noStats: { target: 'stats', kind: 'boolean-negate' }, diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 3f196b11d..08f69f64a 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -599,6 +599,12 @@ export interface AnalyzeOptions { verbose?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ skipAgentsMd?: boolean; + /** + * Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by + * default. Threaded to both the worker (CFG build) and scope-resolution + * (BasicBlock/CFG emit). + */ + pdg?: boolean; /** * Stats inclusion in AGENTS.md and CLAUDE.md. * @@ -1089,6 +1095,8 @@ const analyzeCommandImpl = async ( skipGit: options.skipGit, skipAgentsMd, skipSkills, + // CFG/PDG substrate opt-in (#2081 M1) — threaded to both sinks downstream. + pdg: options.pdg === true, // Resolved default branch (CLI > .gitnexusrc > auto-detect > "main") // threaded into the generated regression-compare example (#243). defaultBranch: resolvedDefaultBranch, diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index a2a88bd83..d519496f2 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -44,6 +44,11 @@ program '(no-op when --index-only is also set).', ) .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') + .option( + '--pdg', + 'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' + + 'for TypeScript/JavaScript. Opt-in; off by default. (#2081 M1)', + ) .option( '--default-branch ', 'Default branch used in the generated regression-compare example (base_ref). ' + diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index fd7fb1d3f..35702b67f 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -115,6 +115,12 @@ export interface AnalyzeOptions { noStats?: boolean; /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */ skipSkills?: boolean; + /** + * Build the CFG/PDG substrate (#2081 M1). Forwarded to `PipelineOptions.pdg`, + * which threads to BOTH the worker (CFG build, via workerData) AND + * scope-resolution (BasicBlock/CFG emit gate). Off by default. + */ + pdg?: boolean; /** * Default branch threaded into generated AGENTS.md / CLAUDE.md so the * regression-compare example uses the configured branch instead of a @@ -506,6 +512,9 @@ export async function runFullAnalysis( { parseCache, workerPoolSize: options.workerPoolSize, + // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker + // build gate (workerData.pdg) and the scope-resolution emit gate. + pdg: options.pdg === true, }, ); diff --git a/gitnexus/test/unit/analyze-config.test.ts b/gitnexus/test/unit/analyze-config.test.ts index 07bbdbea1..0dab5e178 100644 --- a/gitnexus/test/unit/analyze-config.test.ts +++ b/gitnexus/test/unit/analyze-config.test.ts @@ -75,6 +75,17 @@ describe('analyze-config (.gitnexusrc support, #243)', () => { }); }); + it('normalizes the pdg opt-in (#2081) and rejects a non-boolean value', async () => { + await writeRc(JSON.stringify({ pdg: true })); + expect(loadAnalyzeConfig(dir)).toEqual({ pdg: true }); + + await writeRc(JSON.stringify({ pdg: false })); + expect(loadAnalyzeConfig(dir)).toEqual({ pdg: false }); + + await writeRc(JSON.stringify({ pdg: 'yes' })); + expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/); + }); + it('parses the nested analyze form', async () => { await writeRc(JSON.stringify({ analyze: { defaultBranch: 'master', skipSkills: true } })); expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'master', skipSkills: true }); diff --git a/gitnexus/test/unit/cfg/opt-in.test.ts b/gitnexus/test/unit/cfg/opt-in.test.ts new file mode 100644 index 000000000..11a0d45fa --- /dev/null +++ b/gitnexus/test/unit/cfg/opt-in.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAnalyzeOptions } from '../../../src/cli/analyze-config.js'; +import { computeChunkHash } from '../../../src/storage/parse-cache.js'; + +// U5 — the `--pdg` opt-in plumbing (R7). The flag has TWO sinks downstream of +// PipelineOptions.pdg: the parse worker (CFG build, gated on workerData.pdg) +// and scope-resolution (BasicBlock/CFG emit, gated on the run input). These +// tests pin the observable plumbing: the CLI/.gitnexusrc merge, and that the +// flag perturbs the parse-cache/worker-dispatch key so a pdg run never reuses +// a pdg-off shard. The full worker-build + main-emit round-trip is exercised +// end-to-end by the U7 runPipelineFromRepo({ pdg: true }) integration test. + +describe('U5 — --pdg merges from CLI and .gitnexusrc', () => { + it('a CLI --pdg flag flows into the merged options', () => { + expect(mergeAnalyzeOptions({ pdg: true }, undefined).pdg).toBe(true); + }); + + it('a .gitnexusrc pdg value flows through when the CLI omits it', () => { + expect(mergeAnalyzeOptions({}, { pdg: true }).pdg).toBe(true); + }); + + it('the CLI flag wins over the file config', () => { + expect(mergeAnalyzeOptions({ pdg: true }, { pdg: false }).pdg).toBe(true); + }); + + it('absent everywhere ⇒ pdg is undefined (default off)', () => { + expect(mergeAnalyzeOptions({}, undefined).pdg).toBeUndefined(); + }); +}); + +describe('U5 — pdg perturbs the parse-cache / worker-dispatch key', () => { + // The chunk hash is what decides whether a chunk is re-dispatched to the + // workers (and thus whether the worker builds a CFG). Folding pdg in is the + // mechanism that makes the worker sink honor the flag across warm caches. + it('a pdg run computes a different chunk key than a pdg-off run', () => { + const entries = [{ filePath: 'a.ts', contentHash: 'h1' }]; + expect(computeChunkHash(entries, true)).not.toBe(computeChunkHash(entries, false)); + }); +});