gitnexus 1.6.8-rc.12 → 1.6.8-rc.13

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 (36) hide show
  1. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  2. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  3. package/dist/cli/analyze-config.js +1 -0
  4. package/dist/cli/analyze.d.ts +6 -0
  5. package/dist/cli/analyze.js +2 -0
  6. package/dist/cli/index.js +2 -0
  7. package/dist/core/ingestion/cfg/cfg-builder.d.ts +51 -0
  8. package/dist/core/ingestion/cfg/cfg-builder.js +100 -0
  9. package/dist/core/ingestion/cfg/collect.d.ts +30 -0
  10. package/dist/core/ingestion/cfg/collect.js +34 -0
  11. package/dist/core/ingestion/cfg/control-flow-context.d.ts +27 -0
  12. package/dist/core/ingestion/cfg/control-flow-context.js +49 -0
  13. package/dist/core/ingestion/cfg/emit.d.ts +64 -0
  14. package/dist/core/ingestion/cfg/emit.js +95 -0
  15. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  16. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  17. package/dist/core/ingestion/cfg/types.d.ts +66 -0
  18. package/dist/core/ingestion/cfg/types.js +13 -0
  19. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +49 -0
  20. package/dist/core/ingestion/cfg/visitors/typescript.js +492 -0
  21. package/dist/core/ingestion/language-provider.d.ts +11 -0
  22. package/dist/core/ingestion/languages/typescript.js +5 -0
  23. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  24. package/dist/core/ingestion/pipeline.d.ts +23 -0
  25. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +3 -0
  26. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +10 -0
  27. package/dist/core/ingestion/scope-resolution/pipeline/run.js +62 -0
  28. package/dist/core/ingestion/workers/parse-worker.js +40 -1
  29. package/dist/core/ingestion/workers/worker-pool.d.ts +9 -0
  30. package/dist/core/ingestion/workers/worker-pool.js +5 -2
  31. package/dist/core/run-analyze.d.ts +32 -0
  32. package/dist/core/run-analyze.js +75 -1
  33. package/dist/storage/parse-cache.d.ts +22 -1
  34. package/dist/storage/parse-cache.js +20 -10
  35. package/dist/storage/repo-manager.d.ts +31 -7
  36. package/package.json +1 -1
@@ -285,6 +285,9 @@ export const scopeResolutionPhase = {
285
285
  prebuiltNodeLookup: sharedNodeLookup,
286
286
  preExtractedParsedFiles: preExtractedByPath,
287
287
  scopeIndexStorePath: parsedFileStorePath,
288
+ // CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
289
+ pdg: ctx.options?.pdg === true,
290
+ pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
288
291
  recordResolutionOutcome: (outcome) => {
289
292
  resolutionOutcomes.push(outcome);
290
293
  },
@@ -58,6 +58,16 @@ interface RunScopeResolutionInput {
58
58
  readonly treeCache?: {
59
59
  get(filePath: string): unknown;
60
60
  };
61
+ /**
62
+ * CFG/PDG opt-in (#2081 M1). When true, emit BasicBlock nodes + CFG edges
63
+ * from each ParsedFile's worker-built `cfgSideChannel` during Phase-4 graph
64
+ * emission (while the disk store is still live). Default/false ⇒ no CFG
65
+ * nodes or edges and a byte-identical graph.
66
+ */
67
+ readonly pdg?: boolean;
68
+ /** Per-function CFG edge cap. `undefined` ⇒ {@link DEFAULT_MAX_CFG_EDGES_PER_FUNCTION};
69
+ * `0` ⇒ no cap (unlimited). */
70
+ readonly pdgMaxEdgesPerFunction?: number;
61
71
  /**
62
72
  * Optional graph-node lookup built ONCE by the caller and shared across
63
73
  * every language pass. `buildGraphNodeLookup` scans the whole graph and is
@@ -30,6 +30,7 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js';
30
30
  import { finalizeScopeModel } from '../../finalize-orchestrator.js';
31
31
  import { resolveReferenceSites } from '../../resolve-references.js';
32
32
  import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
33
+ import { emitFileCfgs, isEmitSafeCfg, DEFAULT_MAX_CFG_EDGES_PER_FUNCTION } from '../../cfg/emit.js';
33
34
  import { resolveDefGraphId } from '../graph-bridge/ids.js';
34
35
  import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
35
36
  import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
@@ -454,6 +455,67 @@ export function runScopeResolution(input, provider) {
454
455
  resolutionConfig,
455
456
  });
456
457
  }
458
+ // ── CFG/PDG emission (#2081 M1, opt-in via `--pdg`) ──────────────────────
459
+ // Emit BasicBlock nodes + CFG edges from each ParsedFile's worker-built
460
+ // `cfgSideChannel`, HERE — the last point inside scope-resolution where the
461
+ // ParsedFiles are still loaded (`emitParsedFiles` carries the channel; the
462
+ // disk store is cleared right after this orchestrator returns, see phase.ts).
463
+ // A post-`mro` phase would read empty data (KTD1). Off by default ⇒ zero
464
+ // BasicBlock/CFG nodes/edges and a byte-identical graph.
465
+ if (input.pdg === true) {
466
+ let cfgBlocks = 0;
467
+ let cfgEdges = 0;
468
+ let cfgDroppedEdges = 0;
469
+ for (const pf of emitParsedFiles) {
470
+ const cfgs = pf.cfgSideChannel;
471
+ // Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
472
+ // durable store. A stale or wrong-shape value (e.g. a pre-SCHEMA_BUMP
473
+ // shard that slipped the version gate) must skip emission, not throw a
474
+ // TypeError mid-graph-build and abort scope-resolution for the language.
475
+ if (!Array.isArray(cfgs) || cfgs.length === 0)
476
+ continue;
477
+ try {
478
+ // Per-element emit-safety filter (mirrors the parsedfile-store
479
+ // reviver's POLICY: valid elements in a mixed array still emit; junk
480
+ // is warned and skipped). isEmitSafeCfg lives in cfg/emit.ts next to
481
+ // the id templating it defends — see its doc for why anchor-field and
482
+ // endpoint-membership checks are load-bearing. Runs INSIDE the try so
483
+ // even a predicate-time throw (e.g. a hostile getter) is isolated.
484
+ const wellFormed = cfgs.filter(isEmitSafeCfg);
485
+ if (wellFormed.length < cfgs.length) {
486
+ logger.warn(`[cfg] ${pf.filePath}: skipped ${cfgs.length - wellFormed.length} malformed ` +
487
+ `cfgSideChannel element(s) (bad shape, missing id-anchor fields, or edge ` +
488
+ `endpoints matching no block) — CFG for those functions omitted`);
489
+ }
490
+ if (wellFormed.length === 0)
491
+ continue;
492
+ const emitted = emitFileCfgs(graph, wellFormed, input.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
493
+ // Log cap-overflow drops UNCONDITIONALLY (not via input.onWarn, which is
494
+ // gated behind the semantic-model validator and silent in production) so
495
+ // the per-function edge cap never truncates the CFG silently (R6/KTD6).
496
+ (message) => logger.warn(message));
497
+ cfgBlocks += emitted.blocks;
498
+ cfgEdges += emitted.edges;
499
+ cfgDroppedEdges += emitted.droppedEdges;
500
+ }
501
+ catch (err) {
502
+ // Last-resort isolation, mirroring the worker-side per-file try/catch:
503
+ // a shape the predicate misses must cost this one file's CFG, not
504
+ // abort the language's whole scope-resolution pass mid-graph-build.
505
+ // NOTE a mid-emit throw can leave this file's already-inserted
506
+ // BasicBlock nodes in the graph (addNode is not transactional) —
507
+ // orphaned but inert; the predicate keeps every JSON-representable
508
+ // bad shape from reaching this path at all.
509
+ logger.warn(`[cfg] ${pf.filePath}: CFG emission failed (${err instanceof Error ? err.message : String(err)}) — ` +
510
+ `this file's CFG is partial or absent`);
511
+ }
512
+ }
513
+ if (cfgBlocks > 0) {
514
+ logger.debug(`[scope-resolution] CFG emit (lang=${provider.language}): ` +
515
+ `${cfgBlocks} BasicBlock nodes, ${cfgEdges} CFG edges` +
516
+ (cfgDroppedEdges > 0 ? `, ${cfgDroppedEdges} edges dropped (per-function cap)` : ''));
517
+ }
518
+ }
457
519
  if (PROF) {
458
520
  const tEnd = process.hrtime.bigint();
459
521
  const ns = (a, b) => Number(b - a) / 1_000_000;
@@ -69,6 +69,7 @@ import { extractTemplateArguments, templateArgumentsIdTag, templateConstraintsId
69
69
  import { extractParsedFile } from '../scope-extractor-bridge.js';
70
70
  import { persistParsedFileShardSync, persistDurableParsedFileShardSync, } from '../../../storage/parsedfile-store.js';
71
71
  import { extractLaravelRoutes } from '../route-extractors/laravel.js';
72
+ import { collectFunctionCfgs, DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
72
73
  import { logger } from '../../logger.js';
73
74
  // ── ParsedFile store (#1983 parallel serialization) ─────────────────────────
74
75
  // Read ONCE at worker init from `workerData` (immutable for the run, inherited
@@ -86,6 +87,17 @@ const PARSED_FILE_STORE_STORAGE_PATH = workerData?.parsedFileStoreStoragePath;
86
87
  // restores them without re-parsing. `undefined` ⇒ no durable write.
87
88
  const DURABLE_PARSED_FILE_STORAGE_PATH = workerData?.durableParsedFileStoragePath;
88
89
  let shardSeq = 0;
90
+ // ── PDG/CFG opt-in (#2081 M1) ───────────────────────────────────────────────
91
+ // Read ONCE at worker init from `workerData` (the worker never sees
92
+ // PipelineOptions — config arrives via the pool factory's `workerData`, see
93
+ // KTD7 / U5). When `pdg` is set, the worker builds a per-function control-flow
94
+ // graph from the tree-sitter AST (where it lives) and serializes it onto
95
+ // `ParsedFile.cfgSideChannel`. Off ⇒ no CFG work and no field — the default for
96
+ // every run today. `pdgMaxFunctionLines` bounds per-function CFG cost
97
+ // (0/undefined ⇒ no cap; see collectFunctionCfgs).
98
+ const PDG_ENABLED = workerData?.pdg === true;
99
+ const PDG_MAX_FUNCTION_LINES = workerData?.pdgMaxFunctionLines ??
100
+ DEFAULT_PDG_MAX_FUNCTION_LINES;
89
101
  // ── Bootstrap-stage diagnostics (#1741) ────────────────────────────────────
90
102
  // When GITNEXUS_WORKER_BOOTSTRAP=1 (or --verbose sets GITNEXUS_VERBOSE), each
91
103
  // worker reports its startup stage timings to stderr — which the pool tees
@@ -833,7 +845,34 @@ const processFileGroup = (files, language, queryString, result, onFileProcessed)
833
845
  // copy — scopes/defs are carried by reference) to attach the field rather
834
846
  // than mutate the frozen object.
835
847
  const sideChannel = provider.collectCaptureSideChannel?.(file.path);
836
- result.parsedFiles.push(sideChannel !== undefined ? { ...parsedFile, captureSideChannel: sideChannel } : parsedFile);
848
+ let withChannels = sideChannel !== undefined ? { ...parsedFile, captureSideChannel: sideChannel } : parsedFile;
849
+ // CFG side-channel (#2081 M1): build the per-function control-flow graph
850
+ // here, where the tree-sitter AST is still in hand, and attach it as plain
851
+ // serializable data. Only on a --pdg run and only for languages with a
852
+ // cfgVisitor (TS/JS in M1). The same disk-store/warm-cache machinery that
853
+ // carries captureSideChannel carries this — its coherence rests on the
854
+ // SCHEMA_BUMP + the pdg-folded chunk-hash key (see parse-cache.ts).
855
+ if (PDG_ENABLED && provider.cfgVisitor) {
856
+ // Isolate the CFG build per file: a throw here (an unexpected tree-sitter
857
+ // node shape, a deep-nesting stack overflow) must NOT propagate — it
858
+ // would escape processFileGroup to the language-group catch, which treats
859
+ // any throw as "parser unavailable" and silently drops EVERY remaining
860
+ // file in the group. Skip CFG for this one file; parsing + scope
861
+ // resolution proceed unaffected (CFG is a strictly-additive opt-in).
862
+ try {
863
+ const { cfgs } = collectFunctionCfgs(tree.rootNode, provider.cfgVisitor, file.path, PDG_MAX_FUNCTION_LINES);
864
+ if (cfgs.length)
865
+ withChannels = { ...withChannels, cfgSideChannel: cfgs };
866
+ }
867
+ catch (err) {
868
+ const message = `CFG build failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`;
869
+ if (parentPort)
870
+ parentPort.postMessage({ type: 'warning', message });
871
+ else
872
+ logger.warn(message);
873
+ }
874
+ }
875
+ result.parsedFiles.push(withChannels);
837
876
  }
838
877
  // Build per-file type environment + constructor bindings in a single AST walk.
839
878
  // The legacy heritage pre-pass that seeded a file-local parentMap for
@@ -152,6 +152,15 @@ export interface WorkerPoolOptions {
152
152
  * `undefined` ⇒ no durable write.
153
153
  */
154
154
  durableParsedFileStoragePath?: string;
155
+ /**
156
+ * CFG/PDG opt-in (#2081 M1). Baked into every spawned worker's `workerData`
157
+ * (like the store paths above); when `true`, workers build a per-function
158
+ * control-flow graph from the tree-sitter AST and attach it to
159
+ * `ParsedFile.cfgSideChannel`. `undefined`/`false` ⇒ no CFG work.
160
+ */
161
+ pdg?: boolean;
162
+ /** Per-function source-line cap for worker-side CFG construction (0 ⇒ no cap). */
163
+ pdgMaxFunctionLines?: number;
155
164
  }
156
165
  export declare class WorkerPoolDispatchError extends Error {
157
166
  /**
@@ -550,8 +550,11 @@ export const createWorkerPool = (workerUrl, poolSize, options) => {
550
550
  // signature is unchanged so the zero-arg test factories keep working.
551
551
  const parsedFileStoreStoragePath = options?.parsedFileStoreStoragePath;
552
552
  const durableParsedFileStoragePath = options?.durableParsedFileStoragePath;
553
- const workerStoreData = parsedFileStoreStoragePath || durableParsedFileStoragePath
554
- ? { parsedFileStoreStoragePath, durableParsedFileStoragePath }
553
+ // CFG/PDG opt-in (#2081 M1) — carried in workerData alongside the store paths.
554
+ const pdg = options?.pdg === true;
555
+ const pdgMaxFunctionLines = options?.pdgMaxFunctionLines;
556
+ const workerStoreData = parsedFileStoreStoragePath || durableParsedFileStoragePath || pdg
557
+ ? { parsedFileStoreStoragePath, durableParsedFileStoragePath, pdg, pdgMaxFunctionLines }
555
558
  : undefined;
556
559
  const spawnWorker = options?.workerFactory ??
557
560
  ((url) => new Worker(url, {
@@ -8,6 +8,7 @@
8
8
  * IMPORTANT: This module must NEVER call process.exit(). The caller (CLI
9
9
  * wrapper or server worker) is responsible for process lifecycle.
10
10
  */
11
+ import { type RepoMeta } from '../storage/repo-manager.js';
11
12
  export interface AnalyzeCallbacks {
12
13
  onProgress: (phase: string, percent: number, message: string) => void;
13
14
  onLog?: (message: string) => void;
@@ -48,6 +49,19 @@ export interface AnalyzeOptions {
48
49
  noStats?: boolean;
49
50
  /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
50
51
  skipSkills?: boolean;
52
+ /**
53
+ * Build the CFG/PDG substrate (#2081 M1). Forwarded to `PipelineOptions.pdg`,
54
+ * which threads to BOTH the worker (CFG build, via workerData) AND
55
+ * scope-resolution (BasicBlock/CFG emit gate). Off by default.
56
+ */
57
+ pdg?: boolean;
58
+ /** Per-function source-line cap for worker-side CFG construction (#2081 M1).
59
+ * Forwarded to `PipelineOptions.pdgMaxFunctionLines`. No CLI flag in M1 —
60
+ * programmatic / server analyze-worker path only; the worker applies
61
+ * `DEFAULT_PDG_MAX_FUNCTION_LINES` when unset. */
62
+ pdgMaxFunctionLines?: number;
63
+ /** Per-function CFG edge cap. Forwarded to `PipelineOptions.pdgMaxEdgesPerFunction`. */
64
+ pdgMaxEdgesPerFunction?: number;
51
65
  /**
52
66
  * Default branch threaded into generated AGENTS.md / CLAUDE.md so the
53
67
  * regression-compare example uses the configured branch instead of a
@@ -162,4 +176,22 @@ export declare const collectBranchCacheKeys: (storagePath: string, excludeDir?:
162
176
  keys: Set<string>;
163
177
  complete: boolean;
164
178
  }>;
179
+ /**
180
+ * Resolve the requested `--pdg` configuration to the shape recorded in
181
+ * `RepoMeta.pdg`, or `undefined` for a pdg-off run. Caps resolve to their
182
+ * defaults so an explicit-default run compares equal to a default run
183
+ * (`0` = unlimited is preserved as `0`). Pure + exported for testing.
184
+ */
185
+ type PdgOptions = Pick<AnalyzeOptions, 'pdg' | 'pdgMaxFunctionLines' | 'pdgMaxEdgesPerFunction'>;
186
+ export declare const resolvePdgConfig: (options: PdgOptions) => RepoMeta["pdg"];
187
+ /**
188
+ * Whether the requested `--pdg` configuration differs from the one the
189
+ * existing index's DB rows were built under (#2099 F1). An absent recorded
190
+ * stamp means pdg-off (every legacy meta — `--pdg` shipped opt-in). Any
191
+ * mismatch means the incremental writeback (which only persists changed-file
192
+ * nodes) cannot produce a coherent index: off→on would silently drop the
193
+ * freshly built CFG layer, on→off would strand zombie BasicBlocks — so the
194
+ * caller forces a full writeback. Pure + exported for testing.
195
+ */
196
+ export declare const pdgModeMismatch: (recorded: RepoMeta["pdg"], options: PdgOptions) => boolean;
165
197
  export declare function runFullAnalysis(repoPath: string, options: AnalyzeOptions, callbacks: AnalyzeCallbacks): Promise<AnalyzeResult>;
@@ -18,6 +18,8 @@ import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-ind
18
18
  import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
19
19
  import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
20
20
  import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitNexusIgnored, registerRepo, cleanupOldKuzuFiles, INCREMENTAL_SCHEMA_VERSION, } from '../storage/repo-manager.js';
21
+ import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
22
+ import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION } from './ingestion/cfg/emit.js';
21
23
  import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
22
24
  import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental/subgraph-extract.js';
23
25
  import { shadowCandidatesFor } from './incremental/shadow-candidates.js';
@@ -128,6 +130,30 @@ export const collectBranchCacheKeys = async (storagePath, excludeDir) => {
128
130
  }
129
131
  return { keys, complete };
130
132
  };
133
+ export const resolvePdgConfig = (options) => options.pdg === true
134
+ ? {
135
+ maxFunctionLines: options.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES,
136
+ maxEdgesPerFunction: options.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
137
+ }
138
+ : undefined;
139
+ /**
140
+ * Whether the requested `--pdg` configuration differs from the one the
141
+ * existing index's DB rows were built under (#2099 F1). An absent recorded
142
+ * stamp means pdg-off (every legacy meta — `--pdg` shipped opt-in). Any
143
+ * mismatch means the incremental writeback (which only persists changed-file
144
+ * nodes) cannot produce a coherent index: off→on would silently drop the
145
+ * freshly built CFG layer, on→off would strand zombie BasicBlocks — so the
146
+ * caller forces a full writeback. Pure + exported for testing.
147
+ */
148
+ export const pdgModeMismatch = (recorded, options) => {
149
+ const requested = resolvePdgConfig(options);
150
+ if (!requested && !recorded)
151
+ return false;
152
+ if (!requested || !recorded)
153
+ return true;
154
+ return (requested.maxFunctionLines !== recorded.maxFunctionLines ||
155
+ requested.maxEdgesPerFunction !== recorded.maxEdgesPerFunction);
156
+ };
131
157
  export async function runFullAnalysis(repoPath, options, callbacks) {
132
158
  const log = (msg) => callbacks.onLog?.(msg);
133
159
  const progress = (phase, percent, message) => callbacks.onProgress(phase, percent, message);
@@ -265,13 +291,37 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
265
291
  // clear it, the on-disk index may be in a half-state. Cheapest path
266
292
  // back to a known-good index is to wipe + rebuild from scratch.
267
293
  if (existingMeta?.incrementalInProgress) {
268
- log('Previous incremental run did not complete cleanly (incrementalInProgress flag set); ' +
294
+ log(
295
+ // "analyze run", not "incremental run" — since #2099 F1 the flag is a
296
+ // generic dirty marker written by BOTH writeback branches.
297
+ 'Previous analyze run did not complete cleanly (incrementalInProgress flag set); ' +
269
298
  'forcing full rebuild to restore a known-good index.');
270
299
  options = { ...options, force: true };
271
300
  // Reload meta after clearing the flag in-memory; we still want fileHashes
272
301
  // for the post-rebuild meta carry-over, but force=true ensures the
273
302
  // rebuild path executes.
274
303
  }
304
+ // ── pdg-mode flip forces full writeback (#2099 F1) ─────────────────
305
+ // The incremental writeback persists only changed-file nodes, so a pdg
306
+ // config differing from the one the DB rows were built under cannot be
307
+ // reconciled incrementally: off→on silently drops the freshly built CFG
308
+ // layer ("Incremental: changed=0", zero BasicBlock rows), on→off strands
309
+ // zombie blocks for unchanged files. MUST sit before the alreadyUpToDate
310
+ // fast path below — a clean-tree flip would otherwise early-return without
311
+ // running the pipeline at all. The notice is deliberately NOT gated on
312
+ // options.force: --skills implies force with no message of its own, and a
313
+ // mode change deserves a diagnostic regardless of why a rebuild happens.
314
+ if (existingMeta && pdgModeMismatch(existingMeta.pdg, options)) {
315
+ const pdgOn = options.pdg === true;
316
+ const capsOnly = !!existingMeta.pdg && pdgOn; // both-on can only mismatch via caps
317
+ const was = existingMeta.pdg ? 'with --pdg' : 'without --pdg';
318
+ const now = pdgOn ? 'with --pdg' : 'without --pdg';
319
+ log(`pdg mode changed (index built ${was}, this run is ${now}` +
320
+ `${capsOnly ? ', but with different caps' : ''}); forcing a full ` +
321
+ `rebuild so the CFG layer is ${pdgOn ? 'fully persisted' : 'fully removed'}. ` +
322
+ `Tip: set \`pdg: ${pdgOn}\` in .gitnexusrc to pin the mode across runs.`);
323
+ options = { ...options, force: true };
324
+ }
275
325
  // ── Early-return: already up to date ──────────────────────────────
276
326
  if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) {
277
327
  // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
@@ -416,6 +466,11 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
416
466
  }, {
417
467
  parseCache,
418
468
  workerPoolSize: options.workerPoolSize,
469
+ // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker
470
+ // build gate (workerData.pdg) and the scope-resolution emit gate.
471
+ pdg: options.pdg === true,
472
+ pdgMaxFunctionLines: options.pdgMaxFunctionLines,
473
+ pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction,
419
474
  fetchWrappers: options.fetchWrappers,
420
475
  });
421
476
  // ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
@@ -465,6 +520,19 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
465
520
  }
466
521
  else {
467
522
  // Full rebuild path: wipe DB files first.
523
+ // Set the dirty flag BEFORE the wipe whenever a prior meta exists,
524
+ // mirroring the incremental branch above (#2099 F1, KTD2b). Without it a
525
+ // full rebuild crashing between the wipe and the end-of-run saveMeta
526
+ // leaves a meta that vouches for a DB it no longer matches — the next
527
+ // clean-tree run's fast path would certify a destroyed DB (or, after a
528
+ // pdg flip, certify zombie/missing BasicBlock rows indefinitely).
529
+ // toWriteCount: 0 is the full-path sentinel (no incremental write set).
530
+ if (existingMeta) {
531
+ await saveMeta(metaDir, {
532
+ ...existingMeta,
533
+ incrementalInProgress: { startedAt: Date.now(), toWriteCount: 0 },
534
+ });
535
+ }
468
536
  await closeLbug();
469
537
  const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`];
470
538
  for (const f of lbugFiles) {
@@ -846,6 +914,12 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
846
914
  // so a sibling branch's prune can union it and not evict our shards.
847
915
  cacheKeys: [...parseCache.usedKeys],
848
916
  incrementalInProgress: undefined,
917
+ // The effective pdg config this run's DB rows were built under
918
+ // (#2099 F1). `undefined` on pdg-off runs — this meta is a fresh
919
+ // literal (no spread of existingMeta), so omission is what CLEARS the
920
+ // stamp after an on→off flip; the next pdgModeMismatch then compares
921
+ // off==off and incremental eligibility is restored.
922
+ pdg: resolvePdgConfig(options),
849
923
  };
850
924
  await saveMeta(metaDir, meta);
851
925
  // Persist the incremental parse cache for the next run. Wraps in
@@ -53,10 +53,31 @@ export declare const fileContentHash: (content: Buffer | string) => string;
53
53
  * in the chunk. We sort by filePath before hashing so chunks composed of
54
54
  * the same files in different order produce the same key.
55
55
  */
56
+ /** PDG/CFG cache namespace (#2081 M1) — every input that changes the
57
+ * WORKER-EMITTED `cfgSideChannel` must be folded into the chunk key, and
58
+ * ONLY those. The classification test for a future option: does the worker
59
+ * see it (workerData) and does it change the bytes the worker writes to the
60
+ * shard? `pdgMaxEdgesPerFunction` famously fails that test — it is applied
61
+ * at EMIT time on the main thread (scope-resolution run.ts), the worker
62
+ * never receives it, and the cached output is byte-identical across cap
63
+ * values; folding it in (as a prior review round did) only forced a
64
+ * spurious full re-parse on every cap change (#2099 F3). Options that
65
+ * change the PERSISTED GRAPH but not the shard belong in the RepoMeta pdg
66
+ * stamp (incremental-eligibility), not here. */
67
+ export interface PdgCacheKey {
68
+ readonly pdg?: boolean;
69
+ /** Per-function source-line cap (changes WHICH functions get a CFG —
70
+ * applied in the worker, so it shapes the cached shard). Callers must
71
+ * pass the RESOLVED value (the production call site in parse-impl.ts
72
+ * applies the worker's default before folding) so an explicit-default
73
+ * run shares the default run's keys — this function folds whatever it
74
+ * is given verbatim. */
75
+ readonly maxFunctionLines?: number;
76
+ }
56
77
  export declare const computeChunkHash: (entries: Array<{
57
78
  filePath: string;
58
79
  contentHash: string;
59
- }>) => string;
80
+ }>, pdg?: boolean | PdgCacheKey) => string;
60
81
  export declare const mapReplacer: (_key: string, value: unknown) => unknown;
61
82
  export declare const mapReviver: (_key: string, value: unknown) => unknown;
62
83
  /**
@@ -52,7 +52,7 @@ import { fileURLToPath } from 'url';
52
52
  // the main thread (the #1983 OOM). Because the two stores share this version,
53
53
  // any future change to the `ParsedFile` serialization shape MUST bump
54
54
  // SCHEMA_BUMP so both invalidate in lockstep.
55
- const SCHEMA_BUMP = 4;
55
+ const SCHEMA_BUMP = 5; // #2081 M1: ParsedFile gained `cfgSideChannel`
56
56
  const GITNEXUS_PKG_VERSION = (() => {
57
57
  try {
58
58
  // package.json sits at gitnexus/package.json — two levels up from
@@ -92,17 +92,27 @@ const sha256Hex = (input) => createHash('sha256')
92
92
  .digest('hex');
93
93
  /** Stable hash of a single file's contents — used by callers to compose a chunk hash. */
94
94
  export const fileContentHash = (content) => sha256Hex(content);
95
- /**
96
- * Compute the canonical cache key for a chunk's contents.
97
- *
98
- * `entries` is the list of (filePath, file content hash) for every file
99
- * in the chunk. We sort by filePath before hashing so chunks composed of
100
- * the same files in different order produce the same key.
101
- */
102
- export const computeChunkHash = (entries) => {
95
+ export const computeChunkHash = (entries, pdg = false) => {
103
96
  const sorted = [...entries].sort((a, b) => (a.filePath < b.filePath ? -1 : 1));
104
97
  const joined = sorted.map((e) => `${e.filePath}:${e.contentHash}`).join('\n');
105
- return sha256Hex(joined);
98
+ const opts = typeof pdg === 'boolean' ? { pdg } : pdg;
99
+ // pdg-off path keeps its pre-#2081 chunk-KEY format verbatim. Note this does
100
+ // NOT mean caches survive the M1 upgrade: SCHEMA_BUMP 4→5 changed
101
+ // PARSE_CACHE_VERSION, and both loadParseCache (below) and the durable
102
+ // parsedfile-store index hard-invalidate on it — every user pays one full
103
+ // cold re-parse on upgrade regardless of --pdg. Keeping the key format
104
+ // stable only means no SECOND invalidation class is introduced here.
105
+ if (!opts.pdg)
106
+ return sha256Hex(joined);
107
+ // Fold the worker-visible --pdg configuration into the key: the boolean
108
+ // plus `maxFunctionLines` (decides which functions get a CFG at all, in the
109
+ // worker). Without it a warm chunk built under one cap is served to a run
110
+ // with a different cap → a stale/under-built CFG: the #2038-class
111
+ // option-blind-key trap. `def` marks an unset (default) value so two
112
+ // default-cap runs share a key. The emit-time edge cap is deliberately
113
+ // absent — see the PdgCacheKey doc comment.
114
+ const ns = `pdg:1;maxFn=${opts.maxFunctionLines ?? 'def'}`;
115
+ return sha256Hex(`${ns}\n${joined}`);
106
116
  };
107
117
  /**
108
118
  * JSON replacer that round-trips Map/Set instances through plain JSON.
@@ -74,16 +74,18 @@ export interface RepoMeta {
74
74
  */
75
75
  fileHashes?: Record<string, string>;
76
76
  /**
77
- * Crash-recovery dirty flag. Written to meta.json BEFORE any
78
- * destructive DB mutation in an incremental run; cleared on success
79
- * by overwriting meta.json. If a run crashes between, the next run
80
- * sees the flag and forces a full rebuild the cheapest path back
81
- * to a known-good index.
77
+ * Crash-recovery dirty flag a generic marker written to meta.json
78
+ * BEFORE any destructive DB mutation by BOTH writeback branches
79
+ * (incremental since its introduction; full rebuilds over an existing
80
+ * meta since #2099 F1); cleared on success by overwriting meta.json.
81
+ * If a run crashes between, the next run sees the flag and forces a
82
+ * full rebuild — the cheapest path back to a known-good index.
82
83
  */
83
84
  incrementalInProgress?: {
84
- /** When the incremental run started (epoch ms). */
85
+ /** When the run started (epoch ms). */
85
86
  startedAt: number;
86
- /** Number of files in the writable set, for diagnostic logs. */
87
+ /** Number of files in the writable set, for diagnostic logs.
88
+ * `0` on the full-rebuild path (no incremental write set exists). */
87
89
  toWriteCount: number;
88
90
  };
89
91
  /**
@@ -103,6 +105,28 @@ export interface RepoMeta {
103
105
  * branch's still-live shards. Additive/optional; absent in legacy metas.
104
106
  */
105
107
  cacheKeys?: string[];
108
+ /**
109
+ * The effective `--pdg` configuration this index's DB rows were built
110
+ * under (#2099 F1). Presence ≡ the BasicBlock/CFG layer exists in the DB;
111
+ * ABSENT ≡ pdg-off — which covers every legacy meta, since `--pdg`
112
+ * shipped opt-in. Caps are recorded RESOLVED (defaults applied) so an
113
+ * explicit-default run compares equal to a default run. run-analyze
114
+ * compares this against the requested options and forces a full
115
+ * writeback on any mismatch — the incremental path only persists
116
+ * changed-file nodes and would otherwise silently drop (or strand) the
117
+ * CFG layer on a mode flip. Additive/optional, no
118
+ * INCREMENTAL_SCHEMA_VERSION bump (a bump would force a one-time full
119
+ * rebuild for every user). NOTE the removal mechanism is load-bearing:
120
+ * the end-of-run meta is a fresh object literal, NOT a spread of the
121
+ * prior meta, so omitting this field on a pdg-off run is what clears
122
+ * the stamp after an on→off flip.
123
+ */
124
+ pdg?: {
125
+ /** Worker-side per-function source-line cap, resolved (0 = unlimited). */
126
+ maxFunctionLines: number;
127
+ /** Emit-side per-function CFG edge cap, resolved (0 = unlimited). */
128
+ maxEdgesPerFunction: number;
129
+ };
106
130
  }
107
131
  /**
108
132
  * Bumped whenever incremental-indexing invariants change incompatibly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.12",
3
+ "version": "1.6.8-rc.13",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",