diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index df519d502..ab2b1779b 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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'; diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 048ecc52f..6e1afc7bb 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -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'; diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts new file mode 100644 index 000000000..c3d1b3e8f --- /dev/null +++ b/gitnexus/src/cli/analyze-options.ts @@ -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 ` uses `` 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; +} diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 8267082c9..e2208a3c8 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 ` uses `` 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. diff --git a/gitnexus/src/cli/generated-skill.ts b/gitnexus/src/cli/generated-skill.ts new file mode 100644 index 000000000..ab0377f3e --- /dev/null +++ b/gitnexus/src/cli/generated-skill.ts @@ -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; +} diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index 9e46b1e5c..f1dd45c3b 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -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[]; diff --git a/gitnexus/src/core/ingestion/di-extractors/index.ts b/gitnexus/src/core/ingestion/di-extractors/index.ts index 5b679feb1..67dddc39b 100644 --- a/gitnexus/src/core/ingestion/di-extractors/index.ts +++ b/gitnexus/src/core/ingestion/di-extractors/index.ts @@ -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 = new Set(Object.values(SupportedLanguages)); diff --git a/gitnexus/src/core/ingestion/di-extractors/spring.ts b/gitnexus/src/core/ingestion/di-extractors/spring.ts index ea5c8e727..1b0b35f6e 100644 --- a/gitnexus/src/core/ingestion/di-extractors/spring.ts +++ b/gitnexus/src/core/ingestion/di-extractors/spring.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/di-extractors/types.ts b/gitnexus/src/core/ingestion/di-extractors/types.ts new file mode 100644 index 000000000..ee3833f9a --- /dev/null +++ b/gitnexus/src/core/ingestion/di-extractors/types.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/types.ts b/gitnexus/src/core/ingestion/import-resolvers/types.ts index 864206fc3..081972b77 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/types.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/types.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 5717b0de3..15558f689 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -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 { const csharpScan = await scanCSharpProject(repoRoot); diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 0525171be..7e1f3a01c 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -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, diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts new file mode 100644 index 000000000..408e5ca42 --- /dev/null +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts @@ -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; + + /** 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; + + /** Module scope by file path. */ + readonly moduleScopeByFile: ReadonlyMap; + + /** 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; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index c6fc80881..597819f58 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -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; - - /** 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; - - /** Module scope by file path. */ - readonly moduleScopeByFile: ReadonlyMap; - - /** 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; -} +/** 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` view backed by a `K → ScopeId` map plus a diff --git a/gitnexus/src/server/analyze-worker-core.ts b/gitnexus/src/server/analyze-worker-core.ts index 547b29dbd..916cce42c 100644 --- a/gitnexus/src/server/analyze-worker-core.ts +++ b/gitnexus/src/server/analyze-worker-core.ts @@ -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 diff --git a/gitnexus/src/server/analyze-worker-protocol.ts b/gitnexus/src/server/analyze-worker-protocol.ts new file mode 100644 index 000000000..81193598e --- /dev/null +++ b/gitnexus/src/server/analyze-worker-protocol.ts @@ -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; diff --git a/gitnexus/src/server/analyze-worker.ts b/gitnexus/src/server/analyze-worker.ts index 08b32b9d6..dfc00565c 100644 --- a/gitnexus/src/server/analyze-worker.ts +++ b/gitnexus/src/server/analyze-worker.ts @@ -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 diff --git a/gitnexus/src/storage/branch-index.ts b/gitnexus/src/storage/branch-index.ts index 7eca9d47c..96f4a5027 100644 --- a/gitnexus/src/storage/branch-index.ts +++ b/gitnexus/src/storage/branch-index.ts @@ -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 diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index da089a666..c40d4a4ba 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -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; - /** - * 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; - /** - * 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//` 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 => - tryReadMetaFile(metaDir, LEGACY_METADATA_FILE); - -/** - * Load metadata from a directory containing the metadata file (gitnexus.json). - * For primary/flat: metaDir = /.gitnexus - * For feature branches: metaDir = /.gitnexus/branches/ - * - * 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 => { - 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 => }; }; -/** - * 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 => { - 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. */ diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts new file mode 100644 index 000000000..16bd5ede9 --- /dev/null +++ b/gitnexus/src/storage/repo-meta.ts @@ -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; + /** + * 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; + /** + * 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//` 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 => { + 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 => + tryReadMetaFile(metaDir, LEGACY_METADATA_FILE); + +/** + * Load metadata from a directory containing the metadata file (gitnexus.json). + * For primary/flat: metaDir = /.gitnexus + * For feature branches: metaDir = /.gitnexus/branches/ + * + * 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 => { + 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; + } +};