refactor: break the seven small import cycles gitnexus check reports (#2915)

`check` reported 11 cycles. Five are paths inside a single 257-file strongly
connected component in core/ingestion (call-extractors / cfg visitors /
utils/ast-helpers), with a second 26-file component behind it — fixing those
paths would only make check print different ones, so both are left for their own
PR. This closes the seven that are genuinely separable, taking the graph from 9
strongly connected components to 2.

Six of the seven were one value import plus one `import type` edge. tsconfig
sets neither verbatimModuleSyntax nor isolatedModules, so those edges erase
entirely — the cut is a graph and readability change with no emitted-JS
difference. Each moved type went to a leaf module, with a re-export left behind
only where an importer outside the change actually needed it:

- cli/ai-context <-> cli/skill-gen: GeneratedSkillInfo -> cli/generated-skill.ts.
  One importer, no package export surface, so a clean move with no re-export.
- cli/analyze-config <-> cli/analyze (+core/run-analyze): AnalyzeOptions ->
  cli/analyze-options.ts. Re-export kept because a test imports it from
  analyze.js. run-analyze needed no edit — cutting the one type edge collapses
  the 3-file component into a DAG. Its own same-named AnalyzeOptions is a
  different interface and was deliberately not merged.
- ingestion/import-resolvers/types <-> ingestion/language-config: type-only in
  BOTH directions, so it had no runtime existence at all. ImportConfigs has no
  importers outside the pair and is the return type of loadImportConfigs, so it
  moved into language-config. Side effect worth having: the shared resolver
  types module no longer names a single language, which is an AGENTS.md rule for
  core/ingestion shared pipeline code.
- ingestion/di-extractors barrel <-> spring: DiResolver and the two match types
  -> di-extractors/types.ts, following the import-resolvers/types.ts precedent.
- scope-resolution/walkers <-> workspace-index: WorkspaceResolutionIndex ->
  workspace-index-types.ts. Re-export is load-bearing — 9 src importers, 4 test
  files, and a dynamic import() at contract/scope-resolver.ts. Moving the value
  isClassLike instead was rejected: ~15 value importers, and it is documented as
  a pair with isShapeLike.
- server/analyze-worker <-> analyze-worker-core: the WorkerMessage protocol ->
  analyze-worker-protocol.ts, a declarations-only leaf.

storage/branch-index <-> storage/repo-manager was the one genuine two-way
runtime cycle: branch-index called getStoragePaths/loadMeta, repo-manager used
branchSlug/BRANCHES_DIR. branch-index's header conceded the cycle and argued it
was ESM-safe because neither side calls across at module-evaluation time — a
guarantee resting on call ordering rather than structure. Folding
resolveBranchPlacement back the other way does not help, because
BranchSummary.stats is typed RepoMeta['stats'], so RepoMeta had to move either
way. Extracted storage/repo-meta.ts, a leaf importing only fs and path, holding
the metadata read primitives; repo-manager re-exports the public names so all
54 RepoMeta and 50 loadMeta importers are untouched. The moved block diffs
byte-identical against HEAD.

Verified beyond typecheck, because the worker entrypoint is the risky part and
nothing in the suite forks it: emitted analyze-worker.js still contains exactly
one runtime import, and forking the real worker over IPC boots it through
entry -> core -> protocol -> terminal-claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0139ycbqnAorkJGQaZQXUNuv
This commit is contained in:
Gergo Magyar 2026-08-12 10:26:50 +00:00
parent 9ec75f69bd
commit d338eadd8d
20 changed files with 985 additions and 803 deletions

View file

@ -9,7 +9,7 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { type GeneratedSkillInfo } from './skill-gen.js';
import { type GeneratedSkillInfo } from './generated-skill.js';
import { STANDARD_SKILL_CATALOG } from './standard-skills.js';
import { logger } from '../core/logger.js';

View file

@ -30,7 +30,7 @@
import fs from 'node:fs';
import path from 'node:path';
import type { AnalyzeOptions } from './analyze.js';
import type { AnalyzeOptions } from './analyze-options.js';
export const GITNEXUS_RC_FILENAME = '.gitnexusrc';

View file

@ -0,0 +1,131 @@
/**
* CLI-facing `analyze` option shape.
*
* This is the *flag* shape: it mirrors what Commander parses off the command
* line and what `.gitnexusrc` may set, before `analyze` translates it into the
* core orchestrator's own `AnalyzeOptions` (`core/run-analyze.ts`) — a
* different, deliberately separate interface (`stats` here vs `noStats`
* there, `embeddings?: boolean | string` here vs a resolved
* `embeddingsNodeLimit` there).
*
* It lives in this leaf module because both `analyze.ts` (which consumes the
* flags) and `analyze-config.ts` (which maps `.gitnexusrc` keys onto them)
* need it, and `analyze.ts` already imports the config loader — a type import
* back the other way put the two files, plus `core/run-analyze.ts`, in an
* import cycle. `analyze.ts` re-exports the type for existing importers.
*/
export interface AnalyzeOptions {
force?: boolean;
repairFts?: boolean;
/**
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
* - `undefined` when the flag is omitted
* - `true` when passed without an argument (use default 50K node cap)
* - a string when passed with an argument (`--embeddings 0` disables the
* cap, `--embeddings <n>` uses `<n>` as the cap)
*/
embeddings?: boolean | string;
/**
* Explicitly drop existing embeddings on rebuild instead of preserving
* them. Without this flag, a routine `analyze` keeps any embeddings
* already present in the index even when `--embeddings` is omitted.
*/
dropEmbeddings?: boolean;
skills?: boolean;
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.
*
* Commander.js represents `--no-stats` as `stats: boolean` (default
* `true`; `false` when the user passes `--no-stats`), NOT as
* `noStats: boolean`. Reading the negated form would always be
* `undefined` and the flag would silently no-op (#1477). Consumers
* that want "did the user request --no-stats?" should compare with
* `=== false` to distinguish the explicit-off case from the
* default-on case.
*/
stats?: boolean;
/**
* Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run
* makes. Scoped to only those two files (never `git add -A`); no-ops
* silently if neither exists, neither changed, or the commit step itself
* fails (e.g. no git identity configured). See #2639.
*/
selfCommit?: boolean;
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
skipSkills?: boolean;
/**
* Default branch for the generated regression-compare example (#243). From
* `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
* concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
*/
defaultBranch?: string;
/**
* Index-branch selector (#2106). From `--branch`. Distinct from
* `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch
* slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an
* alias for `defaultBranch` and must not change index placement. Defaults to
* the checked-out branch inside `runFullAnalysis` when omitted.
*/
branch?: string;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
* Override the default basename-derived registry `name` with a
* user-supplied alias (#829). Disambiguates repos whose paths share a
* basename. Persisted — subsequent re-analyses of the same path without
* `--name` preserve the alias.
*/
name?: string;
/**
* Allow registration even when another path already uses the same
* `--name` alias (#829). Intentionally a distinct flag from `--force`
* because the user may want to coexist under the same name WITHOUT
* paying the cost of a pipeline re-index. Maps to registerRepo's
* `allowDuplicateName` option end-to-end.
*/
allowDuplicateName?: boolean;
/**
* Override the walker's large-file skip threshold (#991). Value in KB;
* clamped downstream to the tree-sitter 32 MB ceiling. Sets
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
*/
maxFileSize?: string;
/** Override worker sub-batch idle timeout in seconds. */
workerTimeout?: string;
/** Control LadybugDB WAL auto-checkpoint threshold during analyze. */
walCheckpointThreshold?: string;
/** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */
workers?: string;
embeddingThreads?: string;
embeddingBatchSize?: string;
embeddingSubBatchSize?: string;
embeddingDevice?: string;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
* the routes phase, where the cross-file consumer scan unions them with the
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
embeddingBaseUrl?: string;
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */
embeddingModel?: string;
/** Bearer token for the embeddings endpoint. Overrides GITNEXUS_EMBEDDING_API_KEY. Never logged. */
embeddingAuthToken?: string;
/** Embedding vector dimensions (positive integer string). Overrides GITNEXUS_EMBEDDING_DIMS. */
embeddingDims?: string;
}

View file

@ -50,6 +50,7 @@ import {
validateBranchName,
GitNexusRcError,
} from './analyze-config.js';
import type { AnalyzeOptions } from './analyze-options.js';
import { runFullAnalysis } from '../core/run-analyze.js';
import { getRuntimeFingerprint } from '../core/platform/capabilities.js';
import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js';
@ -661,121 +662,14 @@ const restoreAnalyzeEnv = (snap: AnalyzeEnvSnapshot): void => {
}
};
export interface AnalyzeOptions {
force?: boolean;
repairFts?: boolean;
/**
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
* - `undefined` when the flag is omitted
* - `true` when passed without an argument (use default 50K node cap)
* - a string when passed with an argument (`--embeddings 0` disables the
* cap, `--embeddings <n>` uses `<n>` as the cap)
*/
embeddings?: boolean | string;
/**
* Explicitly drop existing embeddings on rebuild instead of preserving
* them. Without this flag, a routine `analyze` keeps any embeddings
* already present in the index even when `--embeddings` is omitted.
*/
dropEmbeddings?: boolean;
skills?: boolean;
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.
*
* Commander.js represents `--no-stats` as `stats: boolean` (default
* `true`; `false` when the user passes `--no-stats`), NOT as
* `noStats: boolean`. Reading the negated form would always be
* `undefined` and the flag would silently no-op (#1477). Consumers
* that want "did the user request --no-stats?" should compare with
* `=== false` to distinguish the explicit-off case from the
* default-on case.
*/
stats?: boolean;
/**
* Opt-in auto-commit of any AGENTS.md/CLAUDE.md changes this `analyze` run
* makes. Scoped to only those two files (never `git add -A`); no-ops
* silently if neither exists, neither changed, or the commit step itself
* fails (e.g. no git identity configured). See #2639.
*/
selfCommit?: boolean;
/** Skip installing standard GitNexus skill files directly under .claude/skills/. */
skipSkills?: boolean;
/**
* Default branch for the generated regression-compare example (#243). From
* `--default-branch`; may also be supplied via `.gitnexusrc`. Resolved to a
* concrete branch (CLI > `.gitnexusrc` > auto-detected origin/HEAD > "main")
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
*/
defaultBranch?: string;
/**
* Index-branch selector (#2106). From `--branch`. Distinct from
* `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch
* slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an
* alias for `defaultBranch` and must not change index placement. Defaults to
* the checked-out branch inside `runFullAnalysis` when omitted.
*/
branch?: string;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
* Override the default basename-derived registry `name` with a
* user-supplied alias (#829). Disambiguates repos whose paths share a
* basename. Persisted — subsequent re-analyses of the same path without
* `--name` preserve the alias.
*/
name?: string;
/**
* Allow registration even when another path already uses the same
* `--name` alias (#829). Intentionally a distinct flag from `--force`
* because the user may want to coexist under the same name WITHOUT
* paying the cost of a pipeline re-index. Maps to registerRepo's
* `allowDuplicateName` option end-to-end.
*/
allowDuplicateName?: boolean;
/**
* Override the walker's large-file skip threshold (#991). Value in KB;
* clamped downstream to the tree-sitter 32 MB ceiling. Sets
* `GITNEXUS_MAX_FILE_SIZE` for the rest of the pipeline.
*/
maxFileSize?: string;
/** Override worker sub-batch idle timeout in seconds. */
workerTimeout?: string;
/** Control LadybugDB WAL auto-checkpoint threshold during analyze. */
walCheckpointThreshold?: string;
/** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */
workers?: string;
embeddingThreads?: string;
embeddingBatchSize?: string;
embeddingSubBatchSize?: string;
embeddingDevice?: string;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
* the routes phase, where the cross-file consumer scan unions them with the
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
embeddingBaseUrl?: string;
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */
embeddingModel?: string;
/** Bearer token for the embeddings endpoint. Overrides GITNEXUS_EMBEDDING_API_KEY. Never logged. */
embeddingAuthToken?: string;
/** Embedding vector dimensions (positive integer string). Overrides GITNEXUS_EMBEDDING_DIMS. */
embeddingDims?: string;
}
/**
* CLI `analyze` flag shape. Defined in `./analyze-options.js` so
* `analyze-config.ts` can reference it without importing this module back —
* that type import closed a cycle over `analyze` → `analyze-config` and
* `analyze` → `run-analyze` → `analyze-config`. Re-exported here because this
* is where callers have always imported it from.
*/
export type { AnalyzeOptions };
/**
* Whether the post-index skill step should run.

View file

@ -0,0 +1,17 @@
/**
* Metadata for one repo-specific skill file generated from a detected
* community.
*
* Produced by `skill-gen`'s `generateSkillFiles` and consumed by `ai-context`
* when it lists the generated skills in AGENTS.md / CLAUDE.md. It lives in this
* leaf module rather than in either of those so the consumer does not have to
* import the producer for a type — `ai-context` already supplies the
* `.agents/` mirror check that `skill-gen` calls, and the two directions
* together made an import cycle.
*/
export interface GeneratedSkillInfo {
name: string;
label: string;
symbolCount: number;
fileCount: number;
}

View file

@ -14,6 +14,7 @@ import { CommunityNode, CommunityMembership } from '../core/ingestion/community-
import { ProcessNode } from '../core/ingestion/process-processor.js';
import { KnowledgeGraph } from '../core/graph/types.js';
import { shouldMirrorSkillsToAgents } from './ai-context.js';
import type { GeneratedSkillInfo } from './generated-skill.js';
const GENERATED_SKILL_PREFIX = 'gitnexus-area-';
const MAX_SKILL_NAME_LENGTH = 64;
@ -23,13 +24,6 @@ const MAX_COMMUNITY_NAME_LENGTH = MAX_SKILL_NAME_LENGTH - GENERATED_SKILL_PREFIX
// TYPES
// ============================================================================
export interface GeneratedSkillInfo {
name: string;
label: string;
symbolCount: number;
fileCount: number;
}
interface AggregatedCommunity {
label: string;
rawIds: string[];

View file

@ -15,56 +15,14 @@
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { GraphNode } from 'gitnexus-shared';
import type { DiResolver } from './types.js';
import { springDiResolver } from './spring.js';
/** A successful injection-site match, produced by a per-language resolver. */
export interface DiInjectionMatch {
/** The requested dependency type name. */
targetTypeName: string;
/** A collection receives every matching provider; a single site may need
* framework-specific named/preferred-provider disambiguation. */
cardinality: 'single' | 'collection';
/** Statically known provider name requested at the injection site. The
* resolver owns the human-readable explanation of that selection. */
namedSelection?: {
name: string;
reason: string;
/** Name-first frameworks may fall back to type only for implicit/default
* names. Explicit names remain strict. */
fallbackToType?: boolean;
};
/** Most injection edges originate at the owning Class. Factory-method
* parameters preserve the Method as the semantic source. */
edgeSource?: 'owner-class' | 'site';
/** Human-readable edge reason. Framework specifics (names, idioms,
* collection wrapper, gating annotation) live in this payload so the
* shared `di` phase stays framework-neutral. */
reason: string;
}
/** Provider metadata used by the shared resolver without naming a framework. */
export interface DiProviderMatch {
/** Provider names and aliases that can satisfy a named injection. */
names: readonly string[];
/** Optional type directly provided by a declaration node, such as a
* framework factory method whose node is not itself a Class. */
providedTypeName?: string;
/** Graph node that declares this provider. The shared phase excludes a
* provider from injection into its own declaration site without knowing the
* framework-specific declaration model. */
declaredByNodeId?: string;
/** Present when the framework marks this as its preferred candidate. The
* value is appended to the emitted edge reason when it disambiguates. */
preferenceReason?: string;
}
/** Per-language DI behavior. Matchers receive whole nodes so the shared phase
* remains ignorant of language/framework-specific property shapes. */
export interface DiResolver {
matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[];
matchProvider(node: GraphNode): DiProviderMatch | null;
}
/** The resolver contract lives in the leaf `./types.js` so an implementation
* can depend on it without depending on this registry (which imports every
* implementation). Re-exported here so consumers can keep importing the
* contract and the registry from one place. */
export type { DiInjectionMatch, DiProviderMatch, DiResolver } from './types.js';
/** All `SupportedLanguages` string values, for narrowing raw graph strings. */
const SUPPORTED_LANGUAGE_VALUES: ReadonlySet<string> = new Set(Object.values(SupportedLanguages));

View file

@ -59,7 +59,7 @@
*/
import type { GraphNode } from 'gitnexus-shared';
import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './index.js';
import type { DiInjectionMatch, DiProviderMatch, DiResolver } from './types.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';

View file

@ -0,0 +1,62 @@
/**
* The DI resolver contract — the types a per-language/per-framework resolver
* implements and the shared `di` pipeline phase consumes.
*
* A leaf module by design: it imports nothing from this directory, so the
* barrel (`./index.ts`, which aggregates the resolver *implementations*) and
* each implementation (`./spring.ts`) can both depend on the contract without
* depending on each other. `./index.ts` re-exports everything here, so
* consumers may keep importing these types from the barrel.
*
* Mirrors the `import-resolvers/types.ts` split of contract from registry.
*/
import type { GraphNode } from 'gitnexus-shared';
/** A successful injection-site match, produced by a per-language resolver. */
export interface DiInjectionMatch {
/** The requested dependency type name. */
targetTypeName: string;
/** A collection receives every matching provider; a single site may need
* framework-specific named/preferred-provider disambiguation. */
cardinality: 'single' | 'collection';
/** Statically known provider name requested at the injection site. The
* resolver owns the human-readable explanation of that selection. */
namedSelection?: {
name: string;
reason: string;
/** Name-first frameworks may fall back to type only for implicit/default
* names. Explicit names remain strict. */
fallbackToType?: boolean;
};
/** Most injection edges originate at the owning Class. Factory-method
* parameters preserve the Method as the semantic source. */
edgeSource?: 'owner-class' | 'site';
/** Human-readable edge reason. Framework specifics (names, idioms,
* collection wrapper, gating annotation) live in this payload so the
* shared `di` phase stays framework-neutral. */
reason: string;
}
/** Provider metadata used by the shared resolver without naming a framework. */
export interface DiProviderMatch {
/** Provider names and aliases that can satisfy a named injection. */
names: readonly string[];
/** Optional type directly provided by a declaration node, such as a
* framework factory method whose node is not itself a Class. */
providedTypeName?: string;
/** Graph node that declares this provider. The shared phase excludes a
* provider from injection into its own declaration site without knowing the
* framework-specific declaration model. */
declaredByNodeId?: string;
/** Present when the framework marks this as its preferred candidate. The
* value is appended to the emitted edge reason when it disambiguates. */
preferenceReason?: string;
}
/** Per-language DI behavior. Matchers receive whole nodes so the shared phase
* remains ignorant of language/framework-specific property shapes. */
export interface DiResolver {
matchInjectionSites(node: GraphNode): readonly DiInjectionMatch[];
matchProvider(node: GraphNode): DiProviderMatch | null;
}

View file

@ -4,14 +4,7 @@
* Extracted from import-resolution.ts to co-locate types with their consumers.
*/
import type {
TsconfigPaths,
GoModuleConfig,
CSharpProjectConfig,
CSharpNamespaceEvidence,
ComposerConfig,
} from '../language-config.js';
import type { SwiftPackageConfig } from '../language-config.js';
import type { ImportConfigs } from '../language-config.js';
import type { SuffixIndex } from './utils.js';
import type { SupportedLanguages } from 'gitnexus-shared';
@ -26,17 +19,6 @@ export type ImportResult =
| { kind: 'package'; files: string[]; dirSuffix: string }
| null;
/** Bundled language-specific configs loaded once per ingestion run. */
export interface ImportConfigs {
tsconfigPaths: TsconfigPaths | null;
goModule: GoModuleConfig | null;
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
csharpNamespaces?: CSharpNamespaceEvidence;
}
/** Pre-built lookup structures for import resolution. Build once, reuse across chunks. */
export interface ImportResolutionContext {
allFilePaths: Set<string>;

View file

@ -2,7 +2,6 @@ import fs from 'fs/promises';
import { createReadStream } from 'fs';
import { createInterface } from 'readline';
import path from 'path';
import type { ImportConfigs } from './import-resolvers/types.js';
import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js';
import { isDev } from './utils/env.js';
@ -470,6 +469,27 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
// BUNDLED CONFIG LOADER
// ============================================================================
/**
* Bundled language-specific configs loaded once per ingestion run — the
* result of {@link loadImportConfigs}, and every field's type is declared
* above in this module.
*
* It lives here rather than in `import-resolvers/types.ts` (its consumer, via
* `ResolveCtx`) so the dependency runs one way: the import-resolver types
* import this bundle, and this module imports nothing from them. Homing the
* producer's result type with the producer also keeps `import-resolvers/
* types.ts` free of per-language names.
*/
export interface ImportConfigs {
tsconfigPaths: TsconfigPaths | null;
goModule: GoModuleConfig | null;
composerConfig: ComposerConfig | null;
swiftPackageConfig: SwiftPackageConfig | null;
csharpConfigs: CSharpProjectConfig[];
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
csharpNamespaces?: CSharpNamespaceEvidence;
}
/** Load all language-specific configs once for an ingestion run. */
export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs> {
const csharpScan = await scanCSharpProject(repoRoot);

View file

@ -31,7 +31,7 @@ import type {
} from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import type { WorkspaceResolutionIndex } from '../workspace-index-types.js';
import {
normalizeQualifiedName,
splitQualifiedName,

View file

@ -0,0 +1,39 @@
/**
* The shape of `WorkspaceResolutionIndex` — the scope-tied lookup tables built
* once per resolution run.
*
* A leaf module by design: it imports nothing from this package, so
* `scope/walkers.ts` can type its `index` parameters against the contract
* without importing the builder module that itself calls into `walkers.ts`.
* `./workspace-index.ts` re-exports this type, so consumers may keep importing
* `WorkspaceResolutionIndex` alongside `buildWorkspaceResolutionIndex` from
* there.
*
* See `./workspace-index.ts` for what belongs in this index versus what belongs
* on `SemanticModel`, and for the builder itself.
*/
import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
export interface WorkspaceResolutionIndex {
/** Class def `nodeId` → that class's `Scope`. */
readonly classScopeByDefId: ReadonlyMap<string, Scope>;
/** Inverse of `classScopeByDefId`: class `Scope.id` → class def `nodeId`.
* Built in the same pass; used by the implicit-`this` overload picker
* in `free-call-fallback.ts` to skip an O(C) reverse scan. */
readonly classScopeIdToDefId: ReadonlyMap<ScopeId, string>;
/** Module scope by file path. */
readonly moduleScopeByFile: ReadonlyMap<string, Scope>;
/** Precomputed `simpleName → first module-local callable def` (the
* workspace-wide fallback of `findExportedDefByName`). Materialized here
* ONCE from the resident module scopes so that fallback is an O(1) lookup
* instead of an O(files) scan over every module scope's bindings on each
* unresolved free call — which, under the disk-backed scopeTree, would
* otherwise fault every module scope in from disk per call (the throughput
* killer). "First module-local callable in `moduleScopeByFile` order" is the
* exact semantics the old scan returned, so it is byte-identical. */
readonly exportedCallableByName: ReadonlyMap<string, SymbolDefinition>;
}

View file

@ -40,30 +40,14 @@
*/
import type { ParsedFile, Scope, ScopeId, ScopeTree, SymbolDefinition } from 'gitnexus-shared';
import type { WorkspaceResolutionIndex } from './workspace-index-types.js';
import { isClassLike } from './scope/walkers.js';
export interface WorkspaceResolutionIndex {
/** Class def `nodeId` → that class's `Scope`. */
readonly classScopeByDefId: ReadonlyMap<string, Scope>;
/** Inverse of `classScopeByDefId`: class `Scope.id` → class def `nodeId`.
* Built in the same pass; used by the implicit-`this` overload picker
* in `free-call-fallback.ts` to skip an O(C) reverse scan. */
readonly classScopeIdToDefId: ReadonlyMap<ScopeId, string>;
/** Module scope by file path. */
readonly moduleScopeByFile: ReadonlyMap<string, Scope>;
/** Precomputed `simpleName → first module-local callable def` (the
* workspace-wide fallback of `findExportedDefByName`). Materialized here
* ONCE from the resident module scopes so that fallback is an O(1) lookup
* instead of an O(files) scan over every module scope's bindings on each
* unresolved free call — which, under the disk-backed scopeTree, would
* otherwise fault every module scope in from disk per call (the throughput
* killer). "First module-local callable in `moduleScopeByFile` order" is the
* exact semantics the old scan returned, so it is byte-identical. */
readonly exportedCallableByName: ReadonlyMap<string, SymbolDefinition>;
}
/** The index *shape* lives in the leaf `./workspace-index-types.js` so
* `scope/walkers.ts` — which this builder calls into — can type against it
* without importing this module back. Re-exported here so consumers keep
* importing the type and the builder from one place. */
export type { WorkspaceResolutionIndex } from './workspace-index-types.js';
/**
* A `ReadonlyMap<K, Scope>` view backed by a `K → ScopeId` map plus a

View file

@ -13,7 +13,11 @@
* worker — only the lightweight `analyze-worker-ipc` projection helper.
*/
import type { AnalyzeOptions } from '../core/run-analyze.js';
import type { WorkerMessage } from './analyze-worker.js';
// The IPC message protocol lives in a declarations-only leaf, NOT in the
// `analyze-worker.ts` entry module: importing it from the entry made this
// module depend on the very module that depends on it (#cycle). Erased at
// runtime either way; this way the graph is acyclic too.
import type { WorkerMessage } from './analyze-worker-protocol.js';
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
// Value import (instanceof): index-lock is a lightweight storage primitive

View file

@ -0,0 +1,66 @@
/**
* Analyze-worker IPC message protocol — the shapes that cross the
* `child_process.fork()` boundary between `analyze-launch.ts` (parent) and
* `analyze-worker.ts` (child).
*
* This module exists so the protocol has a home that neither participant sits
* downstream of. The types previously lived in `analyze-worker.ts`, the fork
* ENTRY module — which meant `analyze-worker-core.ts` (the entry's own
* side-effect-free core) had to import them back from the entry, forming an
* `analyze-worker ⇄ analyze-worker-core` import cycle. The edge was type-only
* and therefore erased at runtime, but it still made the entry module a
* dependency of its own core in every static reader of the graph (bundlers,
* cycle detectors, `gitnexus check`).
*
* Declarations only — no values, no imports of anything that holds a value at
* runtime. Every consumer reaches this module through `import type`, so the
* emitted `analyze-worker-protocol.js` is never loaded by the running worker.
* That matters here specifically: `analyze-worker.ts` is spawned by path from
* build output (`dist/server/analyze-worker.js`, see `analyze-launch.ts`), so a
* module-resolution mistake in this directory surfaces as a worker that never
* sends `ready`, not as a compile error.
*
* `analyze-worker.ts` re-exports the child→parent types so existing import
* sites (`analyze-launch.ts`, tests) keep working unchanged.
*/
import type { AnalyzeOptions } from '../core/run-analyze.js';
import type { AnalyzeResultIpc } from './analyze-worker-ipc.js';
/** Parent → child: the single command that starts an analysis run. */
export interface StartMessage {
type: 'start';
repoPath: string;
options: AnalyzeOptions;
}
export interface ProgressMessage {
type: 'progress';
phase: string;
percent: number;
message: string;
}
export interface CompleteMessage {
type: 'complete';
// JSON-safe projection (no `pipelineResult` / live KnowledgeGraph). This
// channel is default-JSON child_process IPC — see analyze-worker-ipc.ts.
result: AnalyzeResultIpc;
}
export interface ErrorMessage {
type: 'error';
message: string;
/**
* Machine-readable failure code for a parent that wants to branch instead of
* only surfacing the string. `index-lock-timeout` (#2658 review M2) means
* another analyze held the single-writer lock past the wait ceiling — a
* transient, retryable condition, not a broken build. Absent for a generic
* failure.
*/
code?: 'index-lock-timeout';
/** True when the failure is expected to clear on retry (e.g. lock contention). */
retryable?: boolean;
}
/** Child → parent IPC messages. Shared with the parent-side launcher. */
export type WorkerMessage = ProgressMessage | CompleteMessage | ErrorMessage;

View file

@ -11,49 +11,22 @@
* Child -> Parent: { type: 'error', message: string }
*/
import type { AnalyzeOptions } from '../core/run-analyze.js';
import { type AnalyzeResultIpc } from './analyze-worker-ipc.js';
import type { StartMessage, WorkerMessage } from './analyze-worker-protocol.js';
import { runWorkerAnalysis, createTerminalClaim } from './analyze-worker-core.js';
type BoundedCheckpointBeforeExit =
typeof import('../core/lbug/shutdown-helpers.js').boundedCheckpointBeforeExit;
interface StartMessage {
type: 'start';
repoPath: string;
options: AnalyzeOptions;
}
export interface ProgressMessage {
type: 'progress';
phase: string;
percent: number;
message: string;
}
export interface CompleteMessage {
type: 'complete';
// JSON-safe projection (no `pipelineResult` / live KnowledgeGraph). This
// channel is default-JSON child_process IPC — see analyze-worker-ipc.ts.
result: AnalyzeResultIpc;
}
export interface ErrorMessage {
type: 'error';
message: string;
/**
* Machine-readable failure code for a parent that wants to branch instead of
* only surfacing the string. `index-lock-timeout` (#2658 review M2) means
* another analyze held the single-writer lock past the wait ceiling — a
* transient, retryable condition, not a broken build. Absent for a generic
* failure.
*/
code?: 'index-lock-timeout';
/** True when the failure is expected to clear on retry (e.g. lock contention). */
retryable?: boolean;
}
/** Child → parent IPC messages. Shared with the parent-side launcher. */
export type WorkerMessage = ProgressMessage | CompleteMessage | ErrorMessage;
// The message shapes live in `analyze-worker-protocol.ts` — a declarations-only
// leaf neither this entry module nor `analyze-worker-core.ts` sits downstream
// of, which is what breaks the entry ⇄ core import cycle. Re-exported here (as
// types, so the re-export is erased at runtime) to keep every existing import
// site — `analyze-launch.ts` and the unit tests — working unchanged.
export type {
ProgressMessage,
CompleteMessage,
ErrorMessage,
WorkerMessage,
} from './analyze-worker-protocol.js';
function send(msg: WorkerMessage) {
// No try/catch: if the IPC channel is gone, process.send throws

View file

@ -2,16 +2,21 @@
* Branch-index primitives (#2106).
*
* Extracted from `repo-manager.ts` to keep the multi-branch slug/placement
* logic in one focused module. `getStoragePaths`, `loadMeta`, and the registry
* I/O stay in `repo-manager.ts`; this module imports the two it needs at
* call-time only (no module-load cross-calls), so the repo-manager ⇄
* branch-index import cycle is ESM-safe. `repo-manager.ts` re-exports these so
* existing import sites keep working unchanged.
* logic in one focused module. The registry I/O and the metadata WRITE side
* stay in `repo-manager.ts`, which re-exports these so existing import sites
* keep working unchanged.
*
* The metadata READ primitives this module needs (`getStoragePath`, `loadMeta`,
* `RepoMeta`) come from `repo-meta.ts`, a leaf below both modules — NOT from
* `repo-manager.ts`. Importing them from there made the two modules import
* values out of each other, and the only thing keeping that ESM-safe was that
* neither side called across at module-evaluation time. Reading from the layer
* below removes the cycle instead of depending on that timing.
*/
import { createHash } from 'crypto';
import { sanitizeRepoName } from './git.js';
import { getStoragePaths, loadMeta, type RepoMeta } from './repo-manager.js';
import { getStoragePath, loadMeta, type RepoMeta } from './repo-meta.js';
/**
* Per-branch index summary nested under a registry entry (#2106). Records
@ -66,7 +71,10 @@ export const resolveBranchPlacement = async (
): Promise<{ branch?: string }> => {
// Detached HEAD / non-git / no label → flat (CI-safe, byte-identical).
if (!label) return {};
const { storagePath } = getStoragePaths(repoPath);
// The flat slot only — identical to `getStoragePaths(repoPath).storagePath`,
// which is `getStoragePath(repoPath)` verbatim (the `branch` argument only
// ever scopes `lbugPath`/`metaPath`, never `storagePath`).
const storagePath = getStoragePath(repoPath);
const flatMeta = await loadMeta(storagePath);
// The flat slot's owner is authoritative ONLY when it is a non-empty string.
// A corrupt/hand-edited meta (empty string, or a non-string value that slips

View file

@ -22,8 +22,6 @@ import { getInferredRepoName, resolveRepoIdentityRoot, stripUrlCredentials } fro
import { stripWindowsLongPathPrefix } from '../lib/utils.js';
import { writeFileAtomic } from './fs-atomic.js';
import { logger } from '../core/logger.js';
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
import { acquireIndexLock, IndexLockTimeoutError, type IndexLockHandle } from './index-lock.js';
import {
branchSlug,
@ -31,12 +29,31 @@ import {
resolveBranchPlacement,
type BranchSummary,
} from './branch-index.js';
import {
GITNEXUS_DIR,
INDEX_METADATA_FILE,
LEGACY_METADATA_FILE,
getStoragePath,
isMissingFilesystemError,
loadMeta,
tryReadMetaFile,
type AnalyzerRunnerIdentity,
type RepoMeta,
} from './repo-meta.js';
// Re-export the #2106 branch primitives (extracted to branch-index.ts, R10) so
// existing `repo-manager` import sites and tests keep working unchanged.
export { branchSlug, resolveBranchPlacement };
export type { BranchSummary };
// Re-export the metadata primitives (extracted to repo-meta.ts) for the same
// reason. They moved DOWN a layer so `branch-index.ts` can read the flat slot's
// metadata without importing back out of this module — see repo-meta.ts for the
// cycle that made the extraction necessary. `LEGACY_METADATA_FILE` and
// `tryReadMetaFile` stay module-private here, exactly as before.
export { getStoragePath, INDEX_METADATA_FILE, isMissingFilesystemError, loadMeta };
export type { AnalyzerRunnerIdentity, RepoMeta };
/**
* Normalise a repo path for registry comparison across platforms
* (#664 review feedback from @evander-wang).
@ -113,470 +130,6 @@ export const registryPathEquals = (a: string, b: string): boolean =>
export const cloneDirBelongsToEntry = (cloneDir: string, entryPath: string): boolean =>
registryPathEquals(canonicalizePath(cloneDir), canonicalizePath(entryPath));
/**
* Versioned receipt for the analyzer process that produced an index.
*
* Paths identify the resolved runtime and invoked GitNexus entry artifact on
* this machine. The entry artifact is diagnostic (CLI and server-worker entry
* files differ); semantic freshness compares the runtime/build/dependency
* fields. SHA-256 digests make the receipt independently reproducible:
* `invokedArtifact.digest` covers the entry file, `build.digest` covers the
* complete source or distribution tree, and `dependencyRuntime.digest` covers
* the applicable lockfile, resolved runtime package metadata, and every
* content-addressed package payload (including JS/JSON/native/Wasm inputs)
* using the canonicalizations defined in `core/analyzer-identity.ts`.
*/
export interface AnalyzerRunnerIdentity {
schemaVersion: 4;
runtime: {
executablePath: string;
version: string;
platform: string;
architecture: string;
modulesAbi: string;
libc: string;
};
cliVersion: string;
invokedArtifact: {
path: string;
digest: string;
};
build: {
kind: 'source' | 'distribution';
rootPath: string;
canonicalization: 'gitnexus-analyzer-build-v2';
digest: string;
};
dependencyRuntime: {
manifestPath: string;
lockfilePath: string | null;
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4';
packageCount: number;
artifactCount: number;
digest: string;
};
}
export interface RepoMeta {
repoPath: string;
lastCommit: string;
indexedAt: string;
/**
* Analyzer/runtime receipt for the successful run represented by this
* metadata. Optional so indexes written by older GitNexus releases remain
* readable; a missing value means provenance is unknown, never that it
* matches the currently invoked analyzer.
*/
runnerIdentity?: AnalyzerRunnerIdentity;
/**
* Canonical `origin` remote URL captured at index time. Used to
* fingerprint the same logical repo across multiple on-disk clones
* (worktrees, agent workspaces, "clean clone for indexing"). When
* absent (no remote configured, git unavailable, etc.) the repo is
* treated as path-only and sibling-clone detection is skipped.
*/
remoteUrl?: string;
stats?: {
files?: number;
nodes?: number;
edges?: number;
communities?: number;
processes?: number;
embeddings?: number;
};
/**
* Capability stamps for what THIS analyze run actually produced (mirrors
* the meta literal in run-analyze.ts — typed here so the stamp site is
* compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status`
* must never claim 'vector-index' unless the run verified or recreated
* the HNSW index). `fts.status` gained its first programmatic reader in
* #2767: `LocalBackend.ensureInitialized()` compares it against the
* warm connection pool's last-observed value as the dedicated signal
* that `--repair-fts` changed FTS availability (`doctor` still prints
* platform-derived capabilities separately; `graph`/`vectorSearch` remain
* forensic-only). The status unions mirror `CapabilityStatus` /
* `SemanticSearchMode` in core/platform/capabilities.ts; inlined so storage/
* takes no core/ import for a pair of string unions, at the cost of keeping
* the two in sync by hand.
*/
capabilities?: {
graph: { provider: string; status: 'available' | 'degraded' | 'unavailable' };
fts: {
provider: string;
status: 'available' | 'degraded' | 'unavailable';
/**
* Why THIS run ended up without search indexes, when `status` is
* `'unavailable'` (#2841). Mirrors `AnalysisResult.ftsSkipReason` in
* core/run-analyze.ts — the same discriminator that surface already
* reports to the CLI, persisted rather than re-derived because the two
* causes need OPPOSITE handling on the next run:
*
* - `extension-unavailable` — the FTS extension could not load. Healable
* from outside the repo (install it), so the up-to-date fast path
* probes whether it loads now and re-analyzes when it does.
* - `build-failed` — the extension loaded fine and the index BUILD
* failed (e.g. one un-tokenizable pre-existing row, #2544/#2546).
* Deterministic: the same probe would "heal" it into a full
* re-analysis that degrades identically and restamps, forever. Only
* `--repair-fts` or a content change addresses it.
*
* Collapsing both into `status: 'unavailable'` is exactly what made that
* loop reachable. ABSENT on indexes written before #2841 and on the
* `--repair-fts` stamp (which writes `status: 'available'`); `undefined`
* therefore reads as "cause unknown" and keeps the pre-#2841 behaviour.
*/
skipReason?: 'extension-unavailable' | 'build-failed';
};
vectorSearch: {
provider: string;
status: 'vector-index' | 'exact-scan' | 'unavailable';
exactScanLimit: number;
reason?: string;
};
};
/**
* Digest of the graph DDL this index's tables were actually created from
* (`SCHEMA_FINGERPRINT`, core/lbug/schema.ts). On mismatch, runFullAnalysis
* warns and forces a full rebuild, which wipes and recreates the database so
* the tables are built from the current DDL (#2798).
*
* This REPLACED `schemaVersion`, a hand-incremented integer that had to
* predict the same fact and could not: it collided with `main` eight times,
* twice exactly, and an exact clash passed the `===` gate silently. The
* digest is derived, so it cannot collide by accident at this scale (48
* bits; see SCHEMA_FINGERPRINT) — two builds agree exactly when their DDL
* agrees.
*
* ABSENT ≡ mismatch, deliberately. That is the backward-compatibility path:
* every index built by an older GitNexus carries no fingerprint, gets the
* warning, and is rebuilt once against the current schema. Grandfathering
* absence would instead stamp a fresh fingerprint onto a database whose DDL
* was never verified.
*
* Stamped only for git repos — non-git repos never take the incremental path.
* Declared as a plain string rather than importing the constant: that would
* be a RUNTIME value import of core/lbug/schema.ts, pulling the whole DDL and
* its `gitnexus-shared` module graph into every storage/ consumer.
*/
schemaFingerprint?: string;
/**
* Exact versions of independently-gated analysis capabilities produced by
* the successful run. Unlike schemaFingerprint, these may apply only to repos
* containing relevant source files.
*/
analysisFeatures?: Record<string, number>;
/**
* The resolved GITNEXUS_FTS_CJK_SEGMENTATION mode ('none' | 'bigram') the
* existing index's content/description columns were last written under
* (#2331/#2339). On mismatch with the live process's resolved mode,
* runFullAnalysis forces a full rebuild so indexed text and query-time
* segmentation never diverge. Always stamped (never omitted), unlike
* `pdg` below — the default 'none' is itself a meaningful value to
* compare, not an absence.
*/
cjkSegmentation?: string;
/**
* The `FLOAT[N]` width this index's `CodeEmbedding` vector column was
* actually created at — `EMBEDDING_DIMS` (core/lbug/schema.ts), resolved from
* `GITNEXUS_EMBEDDING_DIMS` at module load (#2798). On mismatch with the live
* process's width, runFullAnalysis forces a full rebuild, which wipes the
* database and recreates the table at the new width; an incremental run never
* revisits a column's type, so nothing else can.
*
* Sits beside `schemaFingerprint` rather than inside it on purpose: the
* fingerprint is a digest of CODE, and this width comes from the
* ENVIRONMENT, so folding it in would make the same build disagree with
* itself across two runs and thrash rebuilds.
*
* ABSENT means an index written before this field existed — NOT a mismatch,
* unlike `schemaFingerprint` above. Absence says nothing about the width
* (that run used whatever its env resolved, almost always the 384 default,
* and the table it wrote agreed with it), and every such index also predates
* `schemaFingerprint`, so the guard above already rebuilds it once and this
* stamp lands then. See `embeddingDimsMismatch` for the full argument.
*
* Always stamped, like `cjkSegmentation` and unlike `schemaFingerprint`: the
* column is created for every index, git or not, so there is no case where
* omitting it is correct — which keeps absence meaning exactly one thing.
* A plain number rather than an import of the constant, for the same reason
* `schemaFingerprint` is a plain string: storage/ takes no runtime import of
* core/lbug/schema.ts.
*/
embeddingDims?: number;
/**
* Member names whose call sites were DROPPED because the receiver's type
* could not be established (#2744, the second half of #2708). Read by
* `impact()` / `context()` to report a result as `epistemic: 'lower-bound'`
* instead of `'exact'` when the queried symbol's name appears here.
*
* Keyed by member name, not by target symbol, on purpose: a dropped site's
* callee is unknown by definition, so the drop cannot be attributed to any
* target. Absent when a run dropped nothing, which is the common case and
* keeps `epistemic` exact for cleanly-resolving repos.
*
* The persisted shape IS `UnresolvedReceiverSummary` — referenced, not
* re-declared. The writer stores the whole summary, so a structural mirror
* here silently drops any field added on the producing side (a reader then
* sees `undefined` for keys that are present on disk). Type-only import, so
* this adds no runtime dependency from storage/ on core/.
*/
unresolvedReceiverMembers?: UnresolvedReceiverSummary;
/**
* Interfaces whose structural-satisfaction check this run could not COMPLETE
* (#2873) — not interfaces found to have no implementors.
*
* Read by `impact()` to report `epistemic: 'lower-bound'` instead of
* `'exact'` when a walk crosses one of these interfaces. Without it, an
* interface whose implementors were never decided is byte-identical to one
* that genuinely has none: both are zero IMPLEMENTS edges, and only the
* second is an answer.
*
* Absent when a run decided everything it looked at, which is the common case
* and keeps `epistemic` exact for cleanly-resolving repos. Absence is NOT the
* same as a zeroed record — an index written before this field existed also
* reads as absent, and both correctly mean "no hedge available from here".
*/
undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary;
/**
* SHA-256 of every file's content at the time of the last successful
* indexing run. The next run computes current hashes and diffs against
* this map to determine which files' DB rows must be replaced.
* Map keys are repo-relative paths.
*/
fileHashes?: Record<string, string>;
/**
* Set when a run finished but the persisted edge count came back far short
* of what the pipeline produced — the B2 "refresh reports SUCCESS while the
* index is unusable" failure (observed as edges collapsing 23009 -> 2170,
* and as a missing `CodeRelation` table, which reads here as a persisted
* count of zero).
*
* Recorded rather than thrown because the metadata IS written and the DB
* does hold rows; what is false is the claim that the index is complete.
* `getIndexIncompleteReasons` turns this into `graph-write-collapsed` so
* `status` and the MCP resources report the index as incomplete instead of
* fresh. Absent on a healthy run.
*/
/**
* Fields whose property reads could not be linked because every definition of
* the name lives in ANOTHER language (R3-1).
*
* Persisted because the graph cannot answer this at query time: the unlinked
* reads mint no edge and no node, so the only record that they existed is the
* analyze pass that declined them. Without it, `context()` on such a field
* shows an empty incoming list that is byte-identical to a genuinely unread
* field — and the two demand opposite actions.
*
* Capped at analyze time; a long tail is not more actionable than a short one.
*/
crossLanguageProperties?: readonly { name: string; languages: string[] }[];
graphWriteCollapsed?: {
/** Relationships the pipeline produced in memory. */
expected: number;
/** Relationships readable from the DB after the write. */
persisted: number;
};
/**
* Crash-recovery dirty flag — a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB
* mutation by BOTH writeback branches (incremental since its introduction;
* full rebuilds over an existing meta since #2099 F1); cleared on success
* by overwriting the metadata file. If a run crashes between, the next
* run sees the flag and forces a full rebuild — the cheapest path back
* to a known-good index.
*/
incrementalInProgress?: {
/** When the run started (epoch ms). */
startedAt: number;
/** Last dirty-flag refresh (epoch ms). */
updatedAt?: number;
/** Number of files in the writable set, for diagnostic logs.
* `0` on the full-rebuild path (no incremental write set exists). */
toWriteCount: number;
/** Last completed writeback phase before the process stopped. */
phase?: string;
/** Directly changed/added files before importer expansion. */
directWriteCount?: number;
/** Extra files pulled into the writable set by importer BFS. */
importerExpansion?: number;
/** Files in the effective write set after graph-boundary expansion. */
effectiveWriteCount?: number;
/** Files whose persisted rows were scheduled for deletion. */
deleteCount?: number;
/** Added-file shadow seeds included in importer BFS. */
shadowSeedCount?: number;
/** Importer-BFS chunks dropped by failed IMPORTS queries (#2410 +
* tri-review 4669518496 P2-5). Stamped only when > 0: a dropped chunk
* means the importer expansion silently shrank, so a crash's
* diagnostics must show whether the write set was already
* under-expanded when the run died. */
droppedImporterChunks?: number;
};
/**
* Durable embedding-resume marker, written in two distinct situations that
* `kind` tells apart — see below. A matching runtime resumes from persisted
* hashes and regenerates the pending nodes.
*
* Cleared by a clean run. NOT cleared by a run that completed while dropping
* nodes to endpoint failures (#2790): retaining it is what makes those nodes
* come back, because a plain `analyze` derives `shouldGenerateEmbeddings:
* false` once any embeddings exist, so nothing would ever call the pipeline
* again.
*/
embeddingCheckpoint?: {
at: string;
nodesProcessed: number;
totalNodes: number;
chunksProcessed: number;
model: string;
dimensions: number;
/** `local` or a secret-free SHA-256 fingerprint of the HTTP endpoint identity. */
provider: string;
/**
* Which situation wrote this marker. Absent ≡ `'interrupted'`, so markers
* written by older versions keep the stricter behavior.
*
* - `'interrupted'` — written BEFORE a bounded write window. Its
* `pendingNodeIds` may be half-persisted if the process died mid-window,
* so resume must delete and regenerate them even when a persisted row
* carries the current content hash, and an identity mismatch must fail
* closed: resuming under a foreign model would mix vector spaces.
* - `'partial'` — written AFTER a run that completed but dropped nodes to
* endpoint failures. The pipeline already deleted every row of those
* nodes, so they provably hold ZERO rows. Nothing is at risk from a
* different embedding identity, so an identity mismatch may drop the
* pending set with a warning instead of aborting the run.
* - `'unverified-count'` — written after a run whose embedding count could
* not be measured. `pendingNodeIds` is EMPTY: nothing was dropped and
* nothing needs re-embedding. It exists only to defeat the same-commit
* fast return so the next run re-derives a count, because clearing it
* while `stats.embeddings` still reads a stale zero is what arms a later
* `--force` to wipe live embeddings.
*/
kind?: 'interrupted' | 'partial' | 'unverified-count';
/**
* Consecutive resume attempts that have failed to clear `pendingNodeIds`
* (`'partial'` only). Bounds the retry so a node the endpoint rejects
* deterministically — an oversized chunk, content it refuses — cannot keep
* a repo permanently incomplete. See EMBEDDING_RESUME_MAX_ATTEMPTS.
*/
attempts?: number;
/**
* Nodes to regenerate on resume. For `'interrupted'` these may hold a
* subset of their chunks; for `'partial'` they hold none.
*/
pendingNodeIds?: string[];
};
/**
* Name of the git branch this index represents (#2106). Absent for the
* default/legacy single-branch case so the flat metadata file stays
* byte-identical to pre-multi-branch output. When present in the FLAT
* metadata file, it records which branch "owns" the flat slot (the first
* branch indexed); per-branch indexes under `branches/<slug>/` always carry
* their own `branch`.
*/
branch?: string;
/**
* The parse-cache chunk keys this branch's index needs (#2106 R6). The
* parse-cache and durable parsedfile store live ONCE at the repo root and are
* shared across branches; recording each branch's live chunk keys lets the
* prune step union them so re-analyzing one branch doesn't evict another
* branch's still-live shards. Additive/optional; absent in legacy metas.
*/
cacheKeys?: string[];
/**
* The effective `--pdg` configuration this index's DB rows were built
* under (#2099 F1). Presence ≡ the BasicBlock/CFG layer exists in the DB;
* ABSENT ≡ pdg-off — which covers every legacy meta, since `--pdg`
* shipped opt-in. Caps are recorded RESOLVED (defaults applied) so an
* explicit-default run compares equal to a default run. run-analyze
* compares this against the requested options and forces a full
* writeback on any mismatch — the incremental path only persists
* changed-file nodes and would otherwise silently drop (or strand) the
* CFG layer on a mode flip. Additive/optional: it is metadata, not DDL, so
* it does not move `schemaFingerprint` and costs no rebuild for anyone whose
* pdg mode is unchanged. NOTE the removal mechanism is load-bearing:
* the end-of-run meta is a fresh object literal, NOT a spread of the
* prior meta, so omitting this field on a pdg-off run is what clears
* the stamp after an on→off flip.
*/
pdg?: {
/** Worker-side per-function source-line cap, resolved (0 = unlimited). */
maxFunctionLines: number;
/** Emit-side per-function CFG edge cap, resolved (0 = unlimited). */
maxEdgesPerFunction: number;
/**
* Emit-side per-function REACHING_DEF edge cap, resolved (0 = unlimited;
* #2082 M2). ABSENT on an M1-era stamp — which is exactly what makes
* `pdgModeMismatch` trip on the first M2 run over an M1 index and force
* the full writeback that populates REACHING_DEF rows. Optional in the
* type for that reason; resolved (always present) on every M2+ write.
*/
maxReachingDefEdgesPerFunction?: number;
/**
* Emit-side per-function CDG (control-dependence) edge cap, resolved
* (0 = unlimited; #2085 M5). ABSENT on any pre-M5 stamp — that absence is
* what trips `pdgModeMismatch` on the first CDG-aware run and forces the
* full writeback that materialises CDG edges. Optional for that upgrade
* reason; resolved (always present) on every M5+ write.
*/
maxCdgEdgesPerFunction?: number;
/**
* Per-function taint findings cap, resolved (0 = unlimited; #2083 M3).
* ABSENT on an M1/M2-era stamp — like `maxReachingDefEdgesPerFunction`,
* that absence is what trips `pdgModeMismatch` on the first M3 run and
* forces the full writeback that populates TAINTED/SANITIZES rows.
*/
maxTaintFindingsPerFunction?: number;
/** Per-finding taint hop cap, resolved (0 = unlimited; #2083 M3 KTD6 —
* bounds the persisted hop-encoded `reason`). Optional for the same
* M2-era-stamp upgrade reason as the findings cap. */
maxTaintHops?: number;
/**
* Per-run cross-function caps, resolved (0 = unlimited; #2084 M4 review
* P1-3). ABSENT on an M3-era stamp — that absence trips `pdgModeMismatch`
* on the first run that adds them and forces the full writeback that
* re-materialises TAINT_PATH within bounds. Optional for that upgrade
* reason; resolved (always present) on every post-fix write.
*/
maxInterprocFindings?: number;
maxInterprocHops?: number;
maxInterprocEdges?: number;
/**
* Digest of the built-in taint model the persisted findings were
* produced under (#2083 M3 KTD7/R7). Any model-content change ships a
* new digest → mismatch → full writeback repopulates taint edges
* without `--force`. Optional: absent on pre-M3 stamps.
*/
taintModelVersion?: string;
/**
* Identity of the reaching-definitions solver the persisted REACHING_DEF
* rows were produced under (#2201 review R3). The SSA-sparse rewrite computes
* FULL facts for deep-loop functions the old dense worklist truncated to
* empty (the blocks×64 ceiling no longer fires) — but an existing `--pdg`
* index built under the old solver carries those truncated rows. ABSENT on
* any pre-#2201 stamp, so that absence trips `pdgModeMismatch` on the first
* upgraded run and forces the full writeback that recomputes the now-fuller
* REACHING_DEF coverage without `--force`. Bump the tag on any future change
* that alters which facts the solver emits. Optional for that upgrade reason;
* resolved (always present) on every post-#2201 write.
*/
reachingDefSolver?: string;
/**
* Whether this `--pdg` index recorded the FU-C `CALL_SUMMARY` return-value
* ascent layer (per-callee param→return summary edges). `true` on every
* FU-C+ (v4) write. ABSENT on any pre-FU-C (v3) `--pdg` stamp — that absence
* is what tells `impact`'s PDG mode the index predates CALL_SUMMARY, so it
* surfaces a "no return-value ascent (re-index for CALL_SUMMARY)" note while
* STILL serving the intra slice. CALL_SUMMARY is deliberately NOT a required
* sub-layer for `pdgLayerStatus` to report `'ready'`: a v3 index stays fully
* usable for the intra-procedural statement slice; only the ascent upgrade is
* unavailable. Optional for that back-compat reason.
*/
hasCallSummary?: boolean;
};
}
export interface IndexedRepo {
repoPath: string;
storagePath: string;
@ -611,23 +164,10 @@ export interface RegistryEntry {
branches?: BranchSummary[];
}
const GITNEXUS_DIR = '.gitnexus';
const GITNEXUS_EXCLUDE_ENTRY = `${GITNEXUS_DIR}/`;
export const INDEX_METADATA_FILE = 'gitnexus.json';
// Dual-written mirror of INDEX_METADATA_FILE, kept for backward compatibility
// with consumers that only know the pre-rename filename (see MIGRATION.md).
const LEGACY_METADATA_FILE = 'meta.json';
// ─── Local Storage Helpers ─────────────────────────────────────────────
/**
* Get the .gitnexus storage path for a repository.
* Used for local metadata and caches that are not committed.
*/
export const getStoragePath = (repoPath: string): string => {
return path.join(path.resolve(repoPath), GITNEXUS_DIR);
};
/**
* Get paths to key storage files.
*
@ -711,43 +251,6 @@ export const cleanupOldKuzuFiles = async (
}
};
/**
* Load metadata from the legacy `meta.json` mirror in the given directory.
* Returns null when the file is absent, unreadable, or unparseable — a
* corrupt legacy file is treated the same as a missing one (safe rebuild).
*/
const loadMetaLegacy = async (metaDir: string): Promise<RepoMeta | null> =>
tryReadMetaFile(metaDir, LEGACY_METADATA_FILE);
/**
* Load metadata from a directory containing the metadata file (gitnexus.json).
* For primary/flat: metaDir = <repo>/.gitnexus
* For feature branches: metaDir = <repo>/.gitnexus/branches/<slug>
*
* Falls back to the legacy `meta.json` mirror ONLY when `gitnexus.json` is
* provably absent (ENOENT/ENOTDIR). Any other failure — a parse error, EACCES,
* EIO — returns null instead of silently resurrecting possibly-stale legacy
* content: a corrupt primary file must trigger the same safe full-rebuild path
* a missing index would (the fail-safe `saveMeta`'s docstring relies on), not
* an incremental run over a stale legacy baseline.
*/
export const loadMeta = async (metaDir: string): Promise<RepoMeta | null> => {
let raw: string;
try {
raw = await fs.readFile(path.join(metaDir, INDEX_METADATA_FILE), 'utf-8');
} catch (err) {
// Provably absent → the legacy mirror is the source of truth (pre-rename
// repo, or a mirror-only state). Anything else → fail safe with null.
return isMissingFilesystemError(err) ? loadMetaLegacy(metaDir) : null;
}
try {
return JSON.parse(raw) as RepoMeta;
} catch {
// Corrupt primary file — do NOT mask it with legacy content.
return null;
}
};
/**
* Save metadata to the metadata file (gitnexus.json) in the given directory,
* dual-writing the legacy `meta.json` mirror for backward compatibility.
@ -814,19 +317,6 @@ export const loadRepo = async (repoPath: string): Promise<IndexedRepo | null> =>
};
};
/**
* Best-effort read of one specific metadata filename — no fallback, null on
* any failure (absent, unreadable, or unparseable).
*/
const tryReadMetaFile = async (dir: string, filename: string): Promise<RepoMeta | null> => {
try {
const raw = await fs.readFile(path.join(dir, filename), 'utf-8');
return JSON.parse(raw) as RepoMeta;
} catch {
return null;
}
};
/** `indexedAt` as epoch millis; 0 when absent/unparseable (i.e. oldest). */
const metaTimestamp = (meta: RepoMeta): number => {
const t = Date.parse(meta.indexedAt ?? '');
@ -948,17 +438,6 @@ export function isReadOnlyFilesystemError(err: unknown): boolean {
return code === 'EROFS' || code === 'EACCES' || code === 'EPERM';
}
/**
* True for errors that prove a path is absent (ENOENT/ENOTDIR) — as opposed
* to transient/permission failures (EIO/EACCES/EBUSY…) where the file may
* well still exist. Exported for consumers that need the same "provably
* missing vs not provably absent" distinction (e.g. collectBranchCacheKeys).
*/
export function isMissingFilesystemError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
/**
* Keep .gitnexus/ ignored. It contains local index state and caches.
*/

View file

@ -0,0 +1,571 @@
/**
* Repo metadata primitives — the bottom layer of `storage/`.
*
* Holds the on-disk shape of a GitNexus index's metadata file
* (`.gitnexus/gitnexus.json`, plus its legacy `meta.json` mirror) and the
* read-side helpers that locate and parse it. Nothing here writes, and nothing
* here knows about the global registry.
*
* Why it is its own module: `repo-manager.ts` owns the registry and the write
* side, and `branch-index.ts` (#2106) owns the multi-branch slug/placement
* logic — but `resolveBranchPlacement` has to READ the flat slot's metadata to
* decide who owns it. That made `branch-index` import values back out of
* `repo-manager`, which imports values out of `branch-index`: a genuine
* two-way runtime cycle that was only ESM-safe because neither side touched the
* other at module-evaluation time. Rather than keep relying on that timing,
* the shared read primitives moved DOWN here, where both layers can import them
* and neither imports the other back.
*
* `repo-manager.ts` re-exports the public names (`RepoMeta`,
* `AnalyzerRunnerIdentity`, `getStoragePath`, `loadMeta`, `INDEX_METADATA_FILE`,
* `isMissingFilesystemError`) so every existing import site keeps working
* unchanged.
*
* Imports `node:fs`/`node:path` and two type-only shapes. Keep it that way: a
* value import here would land in every consumer of `storage/`.
*/
import fs from 'fs/promises';
import path from 'path';
import type { UnresolvedReceiverSummary } from '../core/ingestion/scope-resolution/unresolved-receivers.js';
import type { UndecidedSatisfactionSummary } from '../core/ingestion/scope-resolution/undecided-satisfaction.js';
/** The `.gitnexus` directory name, relative to a repo root. */
export const GITNEXUS_DIR = '.gitnexus';
export const INDEX_METADATA_FILE = 'gitnexus.json';
// Dual-written mirror of INDEX_METADATA_FILE, kept for backward compatibility
// with consumers that only know the pre-rename filename (see MIGRATION.md).
export const LEGACY_METADATA_FILE = 'meta.json';
/**
* Versioned receipt for the analyzer process that produced an index.
*
* Paths identify the resolved runtime and invoked GitNexus entry artifact on
* this machine. The entry artifact is diagnostic (CLI and server-worker entry
* files differ); semantic freshness compares the runtime/build/dependency
* fields. SHA-256 digests make the receipt independently reproducible:
* `invokedArtifact.digest` covers the entry file, `build.digest` covers the
* complete source or distribution tree, and `dependencyRuntime.digest` covers
* the applicable lockfile, resolved runtime package metadata, and every
* content-addressed package payload (including JS/JSON/native/Wasm inputs)
* using the canonicalizations defined in `core/analyzer-identity.ts`.
*/
export interface AnalyzerRunnerIdentity {
schemaVersion: 4;
runtime: {
executablePath: string;
version: string;
platform: string;
architecture: string;
modulesAbi: string;
libc: string;
};
cliVersion: string;
invokedArtifact: {
path: string;
digest: string;
};
build: {
kind: 'source' | 'distribution';
rootPath: string;
canonicalization: 'gitnexus-analyzer-build-v2';
digest: string;
};
dependencyRuntime: {
manifestPath: string;
lockfilePath: string | null;
canonicalization: 'gitnexus-analyzer-dependency-runtime-v4';
packageCount: number;
artifactCount: number;
digest: string;
};
}
export interface RepoMeta {
repoPath: string;
lastCommit: string;
indexedAt: string;
/**
* Analyzer/runtime receipt for the successful run represented by this
* metadata. Optional so indexes written by older GitNexus releases remain
* readable; a missing value means provenance is unknown, never that it
* matches the currently invoked analyzer.
*/
runnerIdentity?: AnalyzerRunnerIdentity;
/**
* Canonical `origin` remote URL captured at index time. Used to
* fingerprint the same logical repo across multiple on-disk clones
* (worktrees, agent workspaces, "clean clone for indexing"). When
* absent (no remote configured, git unavailable, etc.) the repo is
* treated as path-only and sibling-clone detection is skipped.
*/
remoteUrl?: string;
stats?: {
files?: number;
nodes?: number;
edges?: number;
communities?: number;
processes?: number;
embeddings?: number;
};
/**
* Capability stamps for what THIS analyze run actually produced (mirrors
* the meta literal in run-analyze.ts — typed here so the stamp site is
* compile-checked; tri-review 4669518496 P1/U3: `vectorSearch.status`
* must never claim 'vector-index' unless the run verified or recreated
* the HNSW index). `fts.status` gained its first programmatic reader in
* #2767: `LocalBackend.ensureInitialized()` compares it against the
* warm connection pool's last-observed value as the dedicated signal
* that `--repair-fts` changed FTS availability (`doctor` still prints
* platform-derived capabilities separately; `graph`/`vectorSearch` remain
* forensic-only). The status unions mirror `CapabilityStatus` /
* `SemanticSearchMode` in core/platform/capabilities.ts; inlined so storage/
* takes no core/ import for a pair of string unions, at the cost of keeping
* the two in sync by hand.
*/
capabilities?: {
graph: { provider: string; status: 'available' | 'degraded' | 'unavailable' };
fts: {
provider: string;
status: 'available' | 'degraded' | 'unavailable';
/**
* Why THIS run ended up without search indexes, when `status` is
* `'unavailable'` (#2841). Mirrors `AnalysisResult.ftsSkipReason` in
* core/run-analyze.ts — the same discriminator that surface already
* reports to the CLI, persisted rather than re-derived because the two
* causes need OPPOSITE handling on the next run:
*
* - `extension-unavailable` — the FTS extension could not load. Healable
* from outside the repo (install it), so the up-to-date fast path
* probes whether it loads now and re-analyzes when it does.
* - `build-failed` — the extension loaded fine and the index BUILD
* failed (e.g. one un-tokenizable pre-existing row, #2544/#2546).
* Deterministic: the same probe would "heal" it into a full
* re-analysis that degrades identically and restamps, forever. Only
* `--repair-fts` or a content change addresses it.
*
* Collapsing both into `status: 'unavailable'` is exactly what made that
* loop reachable. ABSENT on indexes written before #2841 and on the
* `--repair-fts` stamp (which writes `status: 'available'`); `undefined`
* therefore reads as "cause unknown" and keeps the pre-#2841 behaviour.
*/
skipReason?: 'extension-unavailable' | 'build-failed';
};
vectorSearch: {
provider: string;
status: 'vector-index' | 'exact-scan' | 'unavailable';
exactScanLimit: number;
reason?: string;
};
};
/**
* Digest of the graph DDL this index's tables were actually created from
* (`SCHEMA_FINGERPRINT`, core/lbug/schema.ts). On mismatch, runFullAnalysis
* warns and forces a full rebuild, which wipes and recreates the database so
* the tables are built from the current DDL (#2798).
*
* This REPLACED `schemaVersion`, a hand-incremented integer that had to
* predict the same fact and could not: it collided with `main` eight times,
* twice exactly, and an exact clash passed the `===` gate silently. The
* digest is derived, so it cannot collide by accident at this scale (48
* bits; see SCHEMA_FINGERPRINT) — two builds agree exactly when their DDL
* agrees.
*
* ABSENT ≡ mismatch, deliberately. That is the backward-compatibility path:
* every index built by an older GitNexus carries no fingerprint, gets the
* warning, and is rebuilt once against the current schema. Grandfathering
* absence would instead stamp a fresh fingerprint onto a database whose DDL
* was never verified.
*
* Stamped only for git repos — non-git repos never take the incremental path.
* Declared as a plain string rather than importing the constant: that would
* be a RUNTIME value import of core/lbug/schema.ts, pulling the whole DDL and
* its `gitnexus-shared` module graph into every storage/ consumer.
*/
schemaFingerprint?: string;
/**
* Exact versions of independently-gated analysis capabilities produced by
* the successful run. Unlike schemaFingerprint, these may apply only to repos
* containing relevant source files.
*/
analysisFeatures?: Record<string, number>;
/**
* The resolved GITNEXUS_FTS_CJK_SEGMENTATION mode ('none' | 'bigram') the
* existing index's content/description columns were last written under
* (#2331/#2339). On mismatch with the live process's resolved mode,
* runFullAnalysis forces a full rebuild so indexed text and query-time
* segmentation never diverge. Always stamped (never omitted), unlike
* `pdg` below — the default 'none' is itself a meaningful value to
* compare, not an absence.
*/
cjkSegmentation?: string;
/**
* The `FLOAT[N]` width this index's `CodeEmbedding` vector column was
* actually created at — `EMBEDDING_DIMS` (core/lbug/schema.ts), resolved from
* `GITNEXUS_EMBEDDING_DIMS` at module load (#2798). On mismatch with the live
* process's width, runFullAnalysis forces a full rebuild, which wipes the
* database and recreates the table at the new width; an incremental run never
* revisits a column's type, so nothing else can.
*
* Sits beside `schemaFingerprint` rather than inside it on purpose: the
* fingerprint is a digest of CODE, and this width comes from the
* ENVIRONMENT, so folding it in would make the same build disagree with
* itself across two runs and thrash rebuilds.
*
* ABSENT means an index written before this field existed — NOT a mismatch,
* unlike `schemaFingerprint` above. Absence says nothing about the width
* (that run used whatever its env resolved, almost always the 384 default,
* and the table it wrote agreed with it), and every such index also predates
* `schemaFingerprint`, so the guard above already rebuilds it once and this
* stamp lands then. See `embeddingDimsMismatch` for the full argument.
*
* Always stamped, like `cjkSegmentation` and unlike `schemaFingerprint`: the
* column is created for every index, git or not, so there is no case where
* omitting it is correct — which keeps absence meaning exactly one thing.
* A plain number rather than an import of the constant, for the same reason
* `schemaFingerprint` is a plain string: storage/ takes no runtime import of
* core/lbug/schema.ts.
*/
embeddingDims?: number;
/**
* Member names whose call sites were DROPPED because the receiver's type
* could not be established (#2744, the second half of #2708). Read by
* `impact()` / `context()` to report a result as `epistemic: 'lower-bound'`
* instead of `'exact'` when the queried symbol's name appears here.
*
* Keyed by member name, not by target symbol, on purpose: a dropped site's
* callee is unknown by definition, so the drop cannot be attributed to any
* target. Absent when a run dropped nothing, which is the common case and
* keeps `epistemic` exact for cleanly-resolving repos.
*
* The persisted shape IS `UnresolvedReceiverSummary` — referenced, not
* re-declared. The writer stores the whole summary, so a structural mirror
* here silently drops any field added on the producing side (a reader then
* sees `undefined` for keys that are present on disk). Type-only import, so
* this adds no runtime dependency from storage/ on core/.
*/
unresolvedReceiverMembers?: UnresolvedReceiverSummary;
/**
* Interfaces whose structural-satisfaction check this run could not COMPLETE
* (#2873) — not interfaces found to have no implementors.
*
* Read by `impact()` to report `epistemic: 'lower-bound'` instead of
* `'exact'` when a walk crosses one of these interfaces. Without it, an
* interface whose implementors were never decided is byte-identical to one
* that genuinely has none: both are zero IMPLEMENTS edges, and only the
* second is an answer.
*
* Absent when a run decided everything it looked at, which is the common case
* and keeps `epistemic` exact for cleanly-resolving repos. Absence is NOT the
* same as a zeroed record — an index written before this field existed also
* reads as absent, and both correctly mean "no hedge available from here".
*/
undecidedInterfaceSatisfaction?: UndecidedSatisfactionSummary;
/**
* SHA-256 of every file's content at the time of the last successful
* indexing run. The next run computes current hashes and diffs against
* this map to determine which files' DB rows must be replaced.
* Map keys are repo-relative paths.
*/
fileHashes?: Record<string, string>;
/**
* Set when a run finished but the persisted edge count came back far short
* of what the pipeline produced — the B2 "refresh reports SUCCESS while the
* index is unusable" failure (observed as edges collapsing 23009 -> 2170,
* and as a missing `CodeRelation` table, which reads here as a persisted
* count of zero).
*
* Recorded rather than thrown because the metadata IS written and the DB
* does hold rows; what is false is the claim that the index is complete.
* `getIndexIncompleteReasons` turns this into `graph-write-collapsed` so
* `status` and the MCP resources report the index as incomplete instead of
* fresh. Absent on a healthy run.
*/
/**
* Fields whose property reads could not be linked because every definition of
* the name lives in ANOTHER language (R3-1).
*
* Persisted because the graph cannot answer this at query time: the unlinked
* reads mint no edge and no node, so the only record that they existed is the
* analyze pass that declined them. Without it, `context()` on such a field
* shows an empty incoming list that is byte-identical to a genuinely unread
* field — and the two demand opposite actions.
*
* Capped at analyze time; a long tail is not more actionable than a short one.
*/
crossLanguageProperties?: readonly { name: string; languages: string[] }[];
graphWriteCollapsed?: {
/** Relationships the pipeline produced in memory. */
expected: number;
/** Relationships readable from the DB after the write. */
persisted: number;
};
/**
* Crash-recovery dirty flag — a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB
* mutation by BOTH writeback branches (incremental since its introduction;
* full rebuilds over an existing meta since #2099 F1); cleared on success
* by overwriting the metadata file. If a run crashes between, the next
* run sees the flag and forces a full rebuild — the cheapest path back
* to a known-good index.
*/
incrementalInProgress?: {
/** When the run started (epoch ms). */
startedAt: number;
/** Last dirty-flag refresh (epoch ms). */
updatedAt?: number;
/** Number of files in the writable set, for diagnostic logs.
* `0` on the full-rebuild path (no incremental write set exists). */
toWriteCount: number;
/** Last completed writeback phase before the process stopped. */
phase?: string;
/** Directly changed/added files before importer expansion. */
directWriteCount?: number;
/** Extra files pulled into the writable set by importer BFS. */
importerExpansion?: number;
/** Files in the effective write set after graph-boundary expansion. */
effectiveWriteCount?: number;
/** Files whose persisted rows were scheduled for deletion. */
deleteCount?: number;
/** Added-file shadow seeds included in importer BFS. */
shadowSeedCount?: number;
/** Importer-BFS chunks dropped by failed IMPORTS queries (#2410 +
* tri-review 4669518496 P2-5). Stamped only when > 0: a dropped chunk
* means the importer expansion silently shrank, so a crash's
* diagnostics must show whether the write set was already
* under-expanded when the run died. */
droppedImporterChunks?: number;
};
/**
* Durable embedding-resume marker, written in two distinct situations that
* `kind` tells apart — see below. A matching runtime resumes from persisted
* hashes and regenerates the pending nodes.
*
* Cleared by a clean run. NOT cleared by a run that completed while dropping
* nodes to endpoint failures (#2790): retaining it is what makes those nodes
* come back, because a plain `analyze` derives `shouldGenerateEmbeddings:
* false` once any embeddings exist, so nothing would ever call the pipeline
* again.
*/
embeddingCheckpoint?: {
at: string;
nodesProcessed: number;
totalNodes: number;
chunksProcessed: number;
model: string;
dimensions: number;
/** `local` or a secret-free SHA-256 fingerprint of the HTTP endpoint identity. */
provider: string;
/**
* Which situation wrote this marker. Absent ≡ `'interrupted'`, so markers
* written by older versions keep the stricter behavior.
*
* - `'interrupted'` — written BEFORE a bounded write window. Its
* `pendingNodeIds` may be half-persisted if the process died mid-window,
* so resume must delete and regenerate them even when a persisted row
* carries the current content hash, and an identity mismatch must fail
* closed: resuming under a foreign model would mix vector spaces.
* - `'partial'` — written AFTER a run that completed but dropped nodes to
* endpoint failures. The pipeline already deleted every row of those
* nodes, so they provably hold ZERO rows. Nothing is at risk from a
* different embedding identity, so an identity mismatch may drop the
* pending set with a warning instead of aborting the run.
* - `'unverified-count'` — written after a run whose embedding count could
* not be measured. `pendingNodeIds` is EMPTY: nothing was dropped and
* nothing needs re-embedding. It exists only to defeat the same-commit
* fast return so the next run re-derives a count, because clearing it
* while `stats.embeddings` still reads a stale zero is what arms a later
* `--force` to wipe live embeddings.
*/
kind?: 'interrupted' | 'partial' | 'unverified-count';
/**
* Consecutive resume attempts that have failed to clear `pendingNodeIds`
* (`'partial'` only). Bounds the retry so a node the endpoint rejects
* deterministically — an oversized chunk, content it refuses — cannot keep
* a repo permanently incomplete. See EMBEDDING_RESUME_MAX_ATTEMPTS.
*/
attempts?: number;
/**
* Nodes to regenerate on resume. For `'interrupted'` these may hold a
* subset of their chunks; for `'partial'` they hold none.
*/
pendingNodeIds?: string[];
};
/**
* Name of the git branch this index represents (#2106). Absent for the
* default/legacy single-branch case so the flat metadata file stays
* byte-identical to pre-multi-branch output. When present in the FLAT
* metadata file, it records which branch "owns" the flat slot (the first
* branch indexed); per-branch indexes under `branches/<slug>/` always carry
* their own `branch`.
*/
branch?: string;
/**
* The parse-cache chunk keys this branch's index needs (#2106 R6). The
* parse-cache and durable parsedfile store live ONCE at the repo root and are
* shared across branches; recording each branch's live chunk keys lets the
* prune step union them so re-analyzing one branch doesn't evict another
* branch's still-live shards. Additive/optional; absent in legacy metas.
*/
cacheKeys?: string[];
/**
* The effective `--pdg` configuration this index's DB rows were built
* under (#2099 F1). Presence ≡ the BasicBlock/CFG layer exists in the DB;
* ABSENT ≡ pdg-off — which covers every legacy meta, since `--pdg`
* shipped opt-in. Caps are recorded RESOLVED (defaults applied) so an
* explicit-default run compares equal to a default run. run-analyze
* compares this against the requested options and forces a full
* writeback on any mismatch — the incremental path only persists
* changed-file nodes and would otherwise silently drop (or strand) the
* CFG layer on a mode flip. Additive/optional: it is metadata, not DDL, so
* it does not move `schemaFingerprint` and costs no rebuild for anyone whose
* pdg mode is unchanged. NOTE the removal mechanism is load-bearing:
* the end-of-run meta is a fresh object literal, NOT a spread of the
* prior meta, so omitting this field on a pdg-off run is what clears
* the stamp after an on→off flip.
*/
pdg?: {
/** Worker-side per-function source-line cap, resolved (0 = unlimited). */
maxFunctionLines: number;
/** Emit-side per-function CFG edge cap, resolved (0 = unlimited). */
maxEdgesPerFunction: number;
/**
* Emit-side per-function REACHING_DEF edge cap, resolved (0 = unlimited;
* #2082 M2). ABSENT on an M1-era stamp — which is exactly what makes
* `pdgModeMismatch` trip on the first M2 run over an M1 index and force
* the full writeback that populates REACHING_DEF rows. Optional in the
* type for that reason; resolved (always present) on every M2+ write.
*/
maxReachingDefEdgesPerFunction?: number;
/**
* Emit-side per-function CDG (control-dependence) edge cap, resolved
* (0 = unlimited; #2085 M5). ABSENT on any pre-M5 stamp — that absence is
* what trips `pdgModeMismatch` on the first CDG-aware run and forces the
* full writeback that materialises CDG edges. Optional for that upgrade
* reason; resolved (always present) on every M5+ write.
*/
maxCdgEdgesPerFunction?: number;
/**
* Per-function taint findings cap, resolved (0 = unlimited; #2083 M3).
* ABSENT on an M1/M2-era stamp — like `maxReachingDefEdgesPerFunction`,
* that absence is what trips `pdgModeMismatch` on the first M3 run and
* forces the full writeback that populates TAINTED/SANITIZES rows.
*/
maxTaintFindingsPerFunction?: number;
/** Per-finding taint hop cap, resolved (0 = unlimited; #2083 M3 KTD6 —
* bounds the persisted hop-encoded `reason`). Optional for the same
* M2-era-stamp upgrade reason as the findings cap. */
maxTaintHops?: number;
/**
* Per-run cross-function caps, resolved (0 = unlimited; #2084 M4 review
* P1-3). ABSENT on an M3-era stamp — that absence trips `pdgModeMismatch`
* on the first run that adds them and forces the full writeback that
* re-materialises TAINT_PATH within bounds. Optional for that upgrade
* reason; resolved (always present) on every post-fix write.
*/
maxInterprocFindings?: number;
maxInterprocHops?: number;
maxInterprocEdges?: number;
/**
* Digest of the built-in taint model the persisted findings were
* produced under (#2083 M3 KTD7/R7). Any model-content change ships a
* new digest → mismatch → full writeback repopulates taint edges
* without `--force`. Optional: absent on pre-M3 stamps.
*/
taintModelVersion?: string;
/**
* Identity of the reaching-definitions solver the persisted REACHING_DEF
* rows were produced under (#2201 review R3). The SSA-sparse rewrite computes
* FULL facts for deep-loop functions the old dense worklist truncated to
* empty (the blocks×64 ceiling no longer fires) — but an existing `--pdg`
* index built under the old solver carries those truncated rows. ABSENT on
* any pre-#2201 stamp, so that absence trips `pdgModeMismatch` on the first
* upgraded run and forces the full writeback that recomputes the now-fuller
* REACHING_DEF coverage without `--force`. Bump the tag on any future change
* that alters which facts the solver emits. Optional for that upgrade reason;
* resolved (always present) on every post-#2201 write.
*/
reachingDefSolver?: string;
/**
* Whether this `--pdg` index recorded the FU-C `CALL_SUMMARY` return-value
* ascent layer (per-callee param→return summary edges). `true` on every
* FU-C+ (v4) write. ABSENT on any pre-FU-C (v3) `--pdg` stamp — that absence
* is what tells `impact`'s PDG mode the index predates CALL_SUMMARY, so it
* surfaces a "no return-value ascent (re-index for CALL_SUMMARY)" note while
* STILL serving the intra slice. CALL_SUMMARY is deliberately NOT a required
* sub-layer for `pdgLayerStatus` to report `'ready'`: a v3 index stays fully
* usable for the intra-procedural statement slice; only the ascent upgrade is
* unavailable. Optional for that back-compat reason.
*/
hasCallSummary?: boolean;
};
}
/**
* Get the .gitnexus storage path for a repository.
* Used for local metadata and caches that are not committed.
*/
export const getStoragePath = (repoPath: string): string => {
return path.join(path.resolve(repoPath), GITNEXUS_DIR);
};
/**
* True for errors that prove a path is absent (ENOENT/ENOTDIR) — as opposed
* to transient/permission failures (EIO/EACCES/EBUSY…) where the file may
* well still exist. Exported for consumers that need the same "provably
* missing vs not provably absent" distinction (e.g. collectBranchCacheKeys).
*/
export function isMissingFilesystemError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException)?.code;
return code === 'ENOENT' || code === 'ENOTDIR';
}
/**
* Best-effort read of one specific metadata filename — no fallback, null on
* any failure (absent, unreadable, or unparseable).
*/
export const tryReadMetaFile = async (dir: string, filename: string): Promise<RepoMeta | null> => {
try {
const raw = await fs.readFile(path.join(dir, filename), 'utf-8');
return JSON.parse(raw) as RepoMeta;
} catch {
return null;
}
};
/**
* Load metadata from the legacy `meta.json` mirror in the given directory.
* Returns null when the file is absent, unreadable, or unparseable — a
* corrupt legacy file is treated the same as a missing one (safe rebuild).
*/
const loadMetaLegacy = async (metaDir: string): Promise<RepoMeta | null> =>
tryReadMetaFile(metaDir, LEGACY_METADATA_FILE);
/**
* Load metadata from a directory containing the metadata file (gitnexus.json).
* For primary/flat: metaDir = <repo>/.gitnexus
* For feature branches: metaDir = <repo>/.gitnexus/branches/<slug>
*
* Falls back to the legacy `meta.json` mirror ONLY when `gitnexus.json` is
* provably absent (ENOENT/ENOTDIR). Any other failure — a parse error, EACCES,
* EIO — returns null instead of silently resurrecting possibly-stale legacy
* content: a corrupt primary file must trigger the same safe full-rebuild path
* a missing index would (the fail-safe `saveMeta`'s docstring relies on), not
* an incremental run over a stale legacy baseline.
*/
export const loadMeta = async (metaDir: string): Promise<RepoMeta | null> => {
let raw: string;
try {
raw = await fs.readFile(path.join(metaDir, INDEX_METADATA_FILE), 'utf-8');
} catch (err) {
// Provably absent → the legacy mirror is the source of truth (pre-rename
// repo, or a mirror-only state). Anything else → fail safe with null.
return isMissingFilesystemError(err) ? loadMetaLegacy(metaDir) : null;
}
try {
return JSON.parse(raw) as RepoMeta;
} catch {
// Corrupt primary file — do NOT mask it with legacy content.
return null;
}
};