gitnexus 1.6.3-rc.13 → 1.6.3-rc.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/ingestion/finalize-orchestrator.d.ts +63 -0
- package/dist/core/ingestion/finalize-orchestrator.js +139 -0
- package/dist/core/ingestion/import-target-adapter.d.ts +73 -0
- package/dist/core/ingestion/import-target-adapter.js +95 -0
- package/dist/core/ingestion/model/scope-resolution-indexes.d.ts +59 -0
- package/dist/core/ingestion/model/scope-resolution-indexes.js +42 -0
- package/dist/core/ingestion/model/semantic-model.d.ts +25 -0
- package/dist/core/ingestion/model/semantic-model.js +16 -0
- package/dist/core/ingestion/shadow-harness.d.ts +113 -0
- package/dist/core/ingestion/shadow-harness.js +148 -0
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `finalizeScopeModel` — turn a workspace's `ParsedFile[]` into a
|
|
3
|
+
* materialized `ScopeResolutionIndexes` (RFC §3.2 Phase 2; Ring 2 PKG #921).
|
|
4
|
+
*
|
|
5
|
+
* Thin integration glue, per issue #884's boundary: all algorithmic logic
|
|
6
|
+
* lives in `gitnexus-shared` (finalize algorithm #915, the four per-file
|
|
7
|
+
* indexes #913, the method-dispatch materialization #914, the scope tree
|
|
8
|
+
* #912). This file does three things only:
|
|
9
|
+
*
|
|
10
|
+
* 1. Map `ParsedFile[]` → `FinalizeInput` and call shared `finalize()`.
|
|
11
|
+
* 2. Build the four workspace-wide indexes from the union of per-file
|
|
12
|
+
* defs/scopes/modules/qualified-names.
|
|
13
|
+
* 3. Bundle the results into `ScopeResolutionIndexes` for
|
|
14
|
+
* `MutableSemanticModel.attachScopeIndexes(...)`.
|
|
15
|
+
*
|
|
16
|
+
* ## What this module is NOT responsible for
|
|
17
|
+
*
|
|
18
|
+
* - Invoking tree-sitter or running AST walks. That's the extractor (#919).
|
|
19
|
+
* - Per-language import-target resolution. Hooks are plumbed through
|
|
20
|
+
* but default to "unresolved" when no provider supplies them — the
|
|
21
|
+
* real adapters land with #922.
|
|
22
|
+
* - Populating `ReferenceIndex`. That's the resolution phase (#925).
|
|
23
|
+
* - Deciding which language uses registry-primary lookup. That's the
|
|
24
|
+
* flag reader (#924).
|
|
25
|
+
*
|
|
26
|
+
* ## Empty-input behavior
|
|
27
|
+
*
|
|
28
|
+
* When `parsedFiles` is empty (the common case today — no language has
|
|
29
|
+
* migrated yet), the orchestrator produces a valid but empty bundle: all
|
|
30
|
+
* indexes are zero-sized, the scope tree is empty, and
|
|
31
|
+
* `finalize.stats.totalFiles === 0`. This lets downstream consumers
|
|
32
|
+
* safely consult `model.scopes` without branching on presence.
|
|
33
|
+
*/
|
|
34
|
+
import type { FinalizeHooks, ParsedFile, WorkspaceIndex } from '../../_shared/index.js';
|
|
35
|
+
import type { ScopeResolutionIndexes } from './model/scope-resolution-indexes.js';
|
|
36
|
+
/**
|
|
37
|
+
* Options forwarded to the orchestrator. All fields optional so callers
|
|
38
|
+
* that don't yet have per-language hooks (today) get sensible defaults;
|
|
39
|
+
* #922 will populate `hooks.resolveImportTarget` + friends per language.
|
|
40
|
+
*/
|
|
41
|
+
export interface FinalizeOrchestratorOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Hooks forwarded to shared `finalize()`. Any omitted field gets a
|
|
44
|
+
* no-op default: unresolved targets, empty wildcard expansion, append
|
|
45
|
+
* merge for bindings.
|
|
46
|
+
*/
|
|
47
|
+
readonly hooks?: Partial<FinalizeHooks>;
|
|
48
|
+
/**
|
|
49
|
+
* Opaque workspace context forwarded to hooks. `undefined` today; Ring
|
|
50
|
+
* 2 PKG #922 populates this with a real cross-file index for the
|
|
51
|
+
* per-language resolvers.
|
|
52
|
+
*/
|
|
53
|
+
readonly workspaceIndex?: WorkspaceIndex;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Produce a fully materialized `ScopeResolutionIndexes` from the
|
|
57
|
+
* workspace's per-file artifacts.
|
|
58
|
+
*
|
|
59
|
+
* Pure function (given pure hooks). No I/O, no globals consulted. The
|
|
60
|
+
* pipeline calls this once per ingestion run and hands the result to
|
|
61
|
+
* `MutableSemanticModel.attachScopeIndexes`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function finalizeScopeModel(parsedFiles: readonly ParsedFile[], options?: FinalizeOrchestratorOptions): ScopeResolutionIndexes;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `finalizeScopeModel` — turn a workspace's `ParsedFile[]` into a
|
|
3
|
+
* materialized `ScopeResolutionIndexes` (RFC §3.2 Phase 2; Ring 2 PKG #921).
|
|
4
|
+
*
|
|
5
|
+
* Thin integration glue, per issue #884's boundary: all algorithmic logic
|
|
6
|
+
* lives in `gitnexus-shared` (finalize algorithm #915, the four per-file
|
|
7
|
+
* indexes #913, the method-dispatch materialization #914, the scope tree
|
|
8
|
+
* #912). This file does three things only:
|
|
9
|
+
*
|
|
10
|
+
* 1. Map `ParsedFile[]` → `FinalizeInput` and call shared `finalize()`.
|
|
11
|
+
* 2. Build the four workspace-wide indexes from the union of per-file
|
|
12
|
+
* defs/scopes/modules/qualified-names.
|
|
13
|
+
* 3. Bundle the results into `ScopeResolutionIndexes` for
|
|
14
|
+
* `MutableSemanticModel.attachScopeIndexes(...)`.
|
|
15
|
+
*
|
|
16
|
+
* ## What this module is NOT responsible for
|
|
17
|
+
*
|
|
18
|
+
* - Invoking tree-sitter or running AST walks. That's the extractor (#919).
|
|
19
|
+
* - Per-language import-target resolution. Hooks are plumbed through
|
|
20
|
+
* but default to "unresolved" when no provider supplies them — the
|
|
21
|
+
* real adapters land with #922.
|
|
22
|
+
* - Populating `ReferenceIndex`. That's the resolution phase (#925).
|
|
23
|
+
* - Deciding which language uses registry-primary lookup. That's the
|
|
24
|
+
* flag reader (#924).
|
|
25
|
+
*
|
|
26
|
+
* ## Empty-input behavior
|
|
27
|
+
*
|
|
28
|
+
* When `parsedFiles` is empty (the common case today — no language has
|
|
29
|
+
* migrated yet), the orchestrator produces a valid but empty bundle: all
|
|
30
|
+
* indexes are zero-sized, the scope tree is empty, and
|
|
31
|
+
* `finalize.stats.totalFiles === 0`. This lets downstream consumers
|
|
32
|
+
* safely consult `model.scopes` without branching on presence.
|
|
33
|
+
*/
|
|
34
|
+
import { buildDefIndex, buildMethodDispatchIndex, buildModuleScopeIndex, buildQualifiedNameIndex, buildScopeTree, finalize, } from '../../_shared/index.js';
|
|
35
|
+
/**
|
|
36
|
+
* Produce a fully materialized `ScopeResolutionIndexes` from the
|
|
37
|
+
* workspace's per-file artifacts.
|
|
38
|
+
*
|
|
39
|
+
* Pure function (given pure hooks). No I/O, no globals consulted. The
|
|
40
|
+
* pipeline calls this once per ingestion run and hands the result to
|
|
41
|
+
* `MutableSemanticModel.attachScopeIndexes`.
|
|
42
|
+
*/
|
|
43
|
+
export function finalizeScopeModel(parsedFiles, options = {}) {
|
|
44
|
+
const hooks = withDefaultHooks(options.hooks ?? {});
|
|
45
|
+
const workspaceIndex = options.workspaceIndex ?? undefined;
|
|
46
|
+
// ── Step 1: Shared finalize — runs SCC-aware cross-file link + binding
|
|
47
|
+
// materialization. Returns linked imports + merged bindings per module
|
|
48
|
+
// scope + SCC condensation + stats.
|
|
49
|
+
const finalizeInput = {
|
|
50
|
+
files: parsedFiles.map(toFinalizeFile),
|
|
51
|
+
workspaceIndex,
|
|
52
|
+
};
|
|
53
|
+
const finalizeOut = finalize(finalizeInput, hooks);
|
|
54
|
+
// ── Step 2: Workspace-wide indexes built from the per-file unions.
|
|
55
|
+
// These are pure aggregations — no algorithm beyond what the builders
|
|
56
|
+
// in gitnexus-shared already encapsulate (first-write-wins, qname
|
|
57
|
+
// collision buckets, etc.).
|
|
58
|
+
const allScopes = [];
|
|
59
|
+
const allDefs = [];
|
|
60
|
+
const moduleEntries = [];
|
|
61
|
+
const allReferenceSites = [];
|
|
62
|
+
for (const file of parsedFiles) {
|
|
63
|
+
for (const s of file.scopes)
|
|
64
|
+
allScopes.push(s);
|
|
65
|
+
for (const d of file.localDefs)
|
|
66
|
+
allDefs.push(d);
|
|
67
|
+
moduleEntries.push({ filePath: file.filePath, moduleScopeId: file.moduleScope });
|
|
68
|
+
}
|
|
69
|
+
// References kept out of the loop above to centralize list-init.
|
|
70
|
+
allReferenceSites.push(...collectReferenceSites(parsedFiles));
|
|
71
|
+
const scopeTree = buildScopeTree(allScopes);
|
|
72
|
+
const defs = buildDefIndex(allDefs);
|
|
73
|
+
const qualifiedNames = buildQualifiedNameIndex(allDefs);
|
|
74
|
+
const moduleScopes = buildModuleScopeIndex(moduleEntries);
|
|
75
|
+
// ── Step 3: MethodDispatchIndex. Today we lack per-language MRO
|
|
76
|
+
// strategies wired into this orchestrator (that belongs with the
|
|
77
|
+
// HeritageMap bridge, a separate piece of work). Ship an EMPTY index
|
|
78
|
+
// so the bundle shape is consistent; the callbacks return `[]` for
|
|
79
|
+
// every owner and `implementsOf` returns `[]`. Populating this
|
|
80
|
+
// properly is tracked alongside the per-language provider hooks.
|
|
81
|
+
const methodDispatch = buildMethodDispatchIndex({
|
|
82
|
+
owners: [], // empty → no MRO entries; `mroFor(x)` returns the frozen empty array
|
|
83
|
+
computeMro: () => [],
|
|
84
|
+
implementsOf: () => [],
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
scopeTree,
|
|
88
|
+
defs,
|
|
89
|
+
qualifiedNames,
|
|
90
|
+
moduleScopes,
|
|
91
|
+
methodDispatch,
|
|
92
|
+
imports: finalizeOut.imports,
|
|
93
|
+
bindings: finalizeOut.bindings,
|
|
94
|
+
referenceSites: Object.freeze([...allReferenceSites]),
|
|
95
|
+
sccs: finalizeOut.sccs,
|
|
96
|
+
stats: finalizeOut.stats,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
100
|
+
/** Shape-reduce a `ParsedFile` to the narrower `FinalizeFile` the shared
|
|
101
|
+
* algorithm reads. The subset is stable — `FinalizeFile` is a proper
|
|
102
|
+
* subset of `ParsedFile`. */
|
|
103
|
+
function toFinalizeFile(file) {
|
|
104
|
+
return {
|
|
105
|
+
filePath: file.filePath,
|
|
106
|
+
moduleScope: file.moduleScope,
|
|
107
|
+
parsedImports: file.parsedImports,
|
|
108
|
+
localDefs: file.localDefs,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Flatten every file's reference sites into one list. Order reflects
|
|
112
|
+
* input-file order, then capture order inside each file. Deterministic. */
|
|
113
|
+
function collectReferenceSites(parsedFiles) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const file of parsedFiles) {
|
|
116
|
+
for (const site of file.referenceSites)
|
|
117
|
+
out.push(site);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Fill in no-op defaults for any omitted hook. Keeps `finalize()`
|
|
123
|
+
* behavior well-defined for the zero-provider case today:
|
|
124
|
+
*
|
|
125
|
+
* - `resolveImportTarget: () => null` — every import edge ends up
|
|
126
|
+
* `linkStatus: 'unresolved'` (or dynamic-unresolved pass-through).
|
|
127
|
+
* - `expandsWildcardTo: () => []` — wildcards don't materialize.
|
|
128
|
+
* - `mergeBindings: (existing, incoming) => [...existing, ...incoming]`
|
|
129
|
+
* — append without precedence; providers override to implement local-
|
|
130
|
+
* shadows-import and similar rules.
|
|
131
|
+
*/
|
|
132
|
+
function withDefaultHooks(partial) {
|
|
133
|
+
return {
|
|
134
|
+
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
|
|
135
|
+
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
|
|
136
|
+
mergeBindings: partial.mergeBindings ??
|
|
137
|
+
((existing, incoming) => [...existing, ...incoming]),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between CLI-package per-language `ImportResolverFn`s and the
|
|
3
|
+
* shared `FinalizeHooks.resolveImportTarget` contract
|
|
4
|
+
* (RFC §5.2; Ring 2 PKG #922).
|
|
5
|
+
*
|
|
6
|
+
* The shared finalize algorithm (#915) asks one question:
|
|
7
|
+
*
|
|
8
|
+
* resolveImportTarget(targetRaw, fromFile, workspaceIndex): string | null
|
|
9
|
+
*
|
|
10
|
+
* The CLI already has 16 language-specific resolvers satisfying a
|
|
11
|
+
* richer signature:
|
|
12
|
+
*
|
|
13
|
+
* ImportResolverFn(rawImportPath, filePath, resolveCtx): ImportResult
|
|
14
|
+
*
|
|
15
|
+
* This module builds a dispatch adapter — one FinalizeHook implementation
|
|
16
|
+
* that looks up the file's language from its path and delegates to the
|
|
17
|
+
* right per-language resolver. Callers package per-language resolvers +
|
|
18
|
+
* a shared `ResolveCtx` into an opaque `ImportTargetWorkspace` and pass
|
|
19
|
+
* it as `workspaceIndex` to `finalizeScopeModel`.
|
|
20
|
+
*
|
|
21
|
+
* ## What's deliberately NOT here
|
|
22
|
+
*
|
|
23
|
+
* - **Re-implementation of any per-language resolver.** We wrap the
|
|
24
|
+
* existing `importResolver` field on each `LanguageProvider` — the
|
|
25
|
+
* same code path the legacy DAG uses today.
|
|
26
|
+
* - **Dynamic-import handling.** The shared finalize algorithm short-
|
|
27
|
+
* circuits `ParsedImport { kind: 'dynamic-unresolved' }` before
|
|
28
|
+
* calling `resolveImportTarget`, so the adapter never sees those.
|
|
29
|
+
* - **`importPathPreprocessor`.** Preprocessing belongs inside the
|
|
30
|
+
* provider's `interpretImport` hook (which writes the final
|
|
31
|
+
* `ParsedImport.targetRaw`). By the time finalize passes a
|
|
32
|
+
* `targetRaw` to this adapter, it is the string the provider wants
|
|
33
|
+
* resolved verbatim.
|
|
34
|
+
*/
|
|
35
|
+
import { type SupportedLanguages, type WorkspaceIndex } from '../../_shared/index.js';
|
|
36
|
+
import type { ImportResolverFn, ResolveCtx } from './import-resolvers/types.js';
|
|
37
|
+
import type { LanguageProvider } from './language-provider.js';
|
|
38
|
+
/** A single language's resolver bundled with the context it needs. */
|
|
39
|
+
export interface LanguageResolverEntry {
|
|
40
|
+
readonly resolver: ImportResolverFn;
|
|
41
|
+
readonly ctx: ResolveCtx;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The opaque `workspaceIndex` shape recognized by
|
|
45
|
+
* `resolveImportTargetAcrossLanguages`. Built once per ingestion run via
|
|
46
|
+
* `buildImportTargetWorkspace`, threaded through `finalizeScopeModel`.
|
|
47
|
+
*/
|
|
48
|
+
export interface ImportTargetWorkspace {
|
|
49
|
+
readonly perLanguage: ReadonlyMap<SupportedLanguages, LanguageResolverEntry>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Build the workspace index from a map of language → provider. Providers
|
|
53
|
+
* whose `importResolver` is absent are silently skipped (no language will
|
|
54
|
+
* ever hit that branch at dispatch time).
|
|
55
|
+
*
|
|
56
|
+
* The `resolveCtx` is shared across all languages. Callers assemble it
|
|
57
|
+
* once per run (the existing pipeline already does this for the legacy
|
|
58
|
+
* DAG) and hand it to both the legacy resolution path and this factory.
|
|
59
|
+
*/
|
|
60
|
+
export declare function buildImportTargetWorkspace(providers: ReadonlyMap<SupportedLanguages, LanguageProvider>, resolveCtx: ResolveCtx): ImportTargetWorkspace;
|
|
61
|
+
/**
|
|
62
|
+
* The FinalizeHooks-compatible implementation. Dispatches on `fromFile`'s
|
|
63
|
+
* extension → per-language resolver. Returns the first resolved file,
|
|
64
|
+
* or `null` if the resolver returns `null` or doesn't know about the
|
|
65
|
+
* language.
|
|
66
|
+
*
|
|
67
|
+
* Picks the first entry of `files[]` for both `'files'` and `'package'`
|
|
68
|
+
* result kinds — the legacy pipeline uses the whole array, but the
|
|
69
|
+
* shared `finalize()` hook contract is single-file. If the workspace
|
|
70
|
+
* later needs richer semantics (split-target packages), this is the
|
|
71
|
+
* single site to extend.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveImportTargetAcrossLanguages(targetRaw: string, fromFile: string, workspaceIndex: WorkspaceIndex): string | null;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge between CLI-package per-language `ImportResolverFn`s and the
|
|
3
|
+
* shared `FinalizeHooks.resolveImportTarget` contract
|
|
4
|
+
* (RFC §5.2; Ring 2 PKG #922).
|
|
5
|
+
*
|
|
6
|
+
* The shared finalize algorithm (#915) asks one question:
|
|
7
|
+
*
|
|
8
|
+
* resolveImportTarget(targetRaw, fromFile, workspaceIndex): string | null
|
|
9
|
+
*
|
|
10
|
+
* The CLI already has 16 language-specific resolvers satisfying a
|
|
11
|
+
* richer signature:
|
|
12
|
+
*
|
|
13
|
+
* ImportResolverFn(rawImportPath, filePath, resolveCtx): ImportResult
|
|
14
|
+
*
|
|
15
|
+
* This module builds a dispatch adapter — one FinalizeHook implementation
|
|
16
|
+
* that looks up the file's language from its path and delegates to the
|
|
17
|
+
* right per-language resolver. Callers package per-language resolvers +
|
|
18
|
+
* a shared `ResolveCtx` into an opaque `ImportTargetWorkspace` and pass
|
|
19
|
+
* it as `workspaceIndex` to `finalizeScopeModel`.
|
|
20
|
+
*
|
|
21
|
+
* ## What's deliberately NOT here
|
|
22
|
+
*
|
|
23
|
+
* - **Re-implementation of any per-language resolver.** We wrap the
|
|
24
|
+
* existing `importResolver` field on each `LanguageProvider` — the
|
|
25
|
+
* same code path the legacy DAG uses today.
|
|
26
|
+
* - **Dynamic-import handling.** The shared finalize algorithm short-
|
|
27
|
+
* circuits `ParsedImport { kind: 'dynamic-unresolved' }` before
|
|
28
|
+
* calling `resolveImportTarget`, so the adapter never sees those.
|
|
29
|
+
* - **`importPathPreprocessor`.** Preprocessing belongs inside the
|
|
30
|
+
* provider's `interpretImport` hook (which writes the final
|
|
31
|
+
* `ParsedImport.targetRaw`). By the time finalize passes a
|
|
32
|
+
* `targetRaw` to this adapter, it is the string the provider wants
|
|
33
|
+
* resolved verbatim.
|
|
34
|
+
*/
|
|
35
|
+
import { getLanguageFromFilename, } from '../../_shared/index.js';
|
|
36
|
+
/**
|
|
37
|
+
* Build the workspace index from a map of language → provider. Providers
|
|
38
|
+
* whose `importResolver` is absent are silently skipped (no language will
|
|
39
|
+
* ever hit that branch at dispatch time).
|
|
40
|
+
*
|
|
41
|
+
* The `resolveCtx` is shared across all languages. Callers assemble it
|
|
42
|
+
* once per run (the existing pipeline already does this for the legacy
|
|
43
|
+
* DAG) and hand it to both the legacy resolution path and this factory.
|
|
44
|
+
*/
|
|
45
|
+
export function buildImportTargetWorkspace(providers, resolveCtx) {
|
|
46
|
+
const perLanguage = new Map();
|
|
47
|
+
for (const [lang, provider] of providers) {
|
|
48
|
+
if (provider.importResolver === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
perLanguage.set(lang, { resolver: provider.importResolver, ctx: resolveCtx });
|
|
51
|
+
}
|
|
52
|
+
return { perLanguage };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The FinalizeHooks-compatible implementation. Dispatches on `fromFile`'s
|
|
56
|
+
* extension → per-language resolver. Returns the first resolved file,
|
|
57
|
+
* or `null` if the resolver returns `null` or doesn't know about the
|
|
58
|
+
* language.
|
|
59
|
+
*
|
|
60
|
+
* Picks the first entry of `files[]` for both `'files'` and `'package'`
|
|
61
|
+
* result kinds — the legacy pipeline uses the whole array, but the
|
|
62
|
+
* shared `finalize()` hook contract is single-file. If the workspace
|
|
63
|
+
* later needs richer semantics (split-target packages), this is the
|
|
64
|
+
* single site to extend.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveImportTargetAcrossLanguages(targetRaw, fromFile, workspaceIndex) {
|
|
67
|
+
const workspace = workspaceIndex;
|
|
68
|
+
if (workspace === undefined || workspace.perLanguage === undefined)
|
|
69
|
+
return null;
|
|
70
|
+
const lang = getLanguageFromFilename(fromFile);
|
|
71
|
+
if (lang === null)
|
|
72
|
+
return null;
|
|
73
|
+
const entry = workspace.perLanguage.get(lang);
|
|
74
|
+
if (entry === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
let result;
|
|
77
|
+
try {
|
|
78
|
+
result = entry.resolver(targetRaw, fromFile, entry.ctx);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Existing resolvers can throw on malformed inputs (e.g., Python
|
|
82
|
+
// relative paths above the workspace root). Swallow — the shared
|
|
83
|
+
// algorithm treats a null here as `linkStatus: 'unresolved'`, which
|
|
84
|
+
// is the right fallback.
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
if (result === null)
|
|
88
|
+
return null;
|
|
89
|
+
// Both `files` and `package` variants expose a `files` array; the
|
|
90
|
+
// package variant also carries `dirSuffix` which we ignore at the
|
|
91
|
+
// FinalizeHook boundary (single-file contract). Legacy consumers
|
|
92
|
+
// continue to see the full result via `importResolver` directly.
|
|
93
|
+
const first = result.files[0];
|
|
94
|
+
return first ?? null;
|
|
95
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ScopeResolutionIndexes` — the bundle of materialized indexes produced
|
|
3
|
+
* by the finalize-orchestrator (RFC #909 Ring 2 PKG #921) and attached
|
|
4
|
+
* to `MutableSemanticModel`.
|
|
5
|
+
*
|
|
6
|
+
* Produced by `finalizeScopeModel(parsedFiles, hooks)` in
|
|
7
|
+
* `finalize-orchestrator.ts`. Consumed by the resolution phase (future
|
|
8
|
+
* tickets) where `Registry.lookup` / `resolveTypeRef` query this bundle
|
|
9
|
+
* to answer call-resolution questions without re-walking any AST.
|
|
10
|
+
*
|
|
11
|
+
* ## Lifecycle
|
|
12
|
+
*
|
|
13
|
+
* 1. Pipeline collects `ParsedFile[]` from the parsing-processor (#920).
|
|
14
|
+
* 2. Pipeline invokes `finalizeScopeModel(parsedFiles, hooks)` →
|
|
15
|
+
* returns a `ScopeResolutionIndexes` (this interface).
|
|
16
|
+
* 3. Pipeline calls `model.attachScopeIndexes(indexes)` to stamp them
|
|
17
|
+
* onto the `MutableSemanticModel`. This is a **one-shot write**;
|
|
18
|
+
* subsequent calls throw. After attachment, the indexes are frozen
|
|
19
|
+
* at the type level (everything is `readonly`) and at runtime via
|
|
20
|
+
* `Object.freeze` on the bundle.
|
|
21
|
+
* 4. Resolution callers hold a `SemanticModel` reference and read
|
|
22
|
+
* `model.scopes` to query.
|
|
23
|
+
*
|
|
24
|
+
* ## Content
|
|
25
|
+
*
|
|
26
|
+
* - `scopeTree` / `moduleScopes` / `defs` / `qualifiedNames` — the
|
|
27
|
+
* four Ring 2 SHARED indexes built over per-file artifacts.
|
|
28
|
+
* - `methodDispatch` — MRO + implements materialized view (#914).
|
|
29
|
+
* - `imports` — finalized `ImportEdge[]` per module scope (`parsedImports`
|
|
30
|
+
* resolved through cross-file link + wildcard expansion).
|
|
31
|
+
* - `bindings` — merged bindings per module scope (local + import +
|
|
32
|
+
* wildcard + re-export), with the provider's precedence applied.
|
|
33
|
+
* - `referenceSites` — union of every file's pre-resolution usage
|
|
34
|
+
* facts. Consumed by the resolution phase (future) to emit
|
|
35
|
+
* `Reference` records into `ReferenceIndex`.
|
|
36
|
+
* - `stats` — coarse-grained counts from the shared finalize algorithm
|
|
37
|
+
* (total files/edges, linked vs unresolved, SCC topology).
|
|
38
|
+
*
|
|
39
|
+
* `ReferenceIndex` is deliberately NOT here — it is populated in a later
|
|
40
|
+
* phase (RFC §3.2 Phase 4 / Ring 2 PKG #925) and owned separately.
|
|
41
|
+
*/
|
|
42
|
+
import type { BindingRef, DefIndex, FinalizedScc, FinalizeStats, ImportEdge, MethodDispatchIndex, ModuleScopeIndex, QualifiedNameIndex, ReferenceSite, ScopeId, ScopeTree } from '../../../_shared/index.js';
|
|
43
|
+
export interface ScopeResolutionIndexes {
|
|
44
|
+
readonly scopeTree: ScopeTree;
|
|
45
|
+
readonly defs: DefIndex;
|
|
46
|
+
readonly qualifiedNames: QualifiedNameIndex;
|
|
47
|
+
readonly moduleScopes: ModuleScopeIndex;
|
|
48
|
+
readonly methodDispatch: MethodDispatchIndex;
|
|
49
|
+
/** Finalized `ImportEdge[]` per module scope. */
|
|
50
|
+
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
|
51
|
+
/** Merged bindings (local + imports + wildcards) per module scope. */
|
|
52
|
+
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
|
53
|
+
/** Pre-resolution usage facts; consumed by the resolution phase. */
|
|
54
|
+
readonly referenceSites: readonly ReferenceSite[];
|
|
55
|
+
/** SCC condensation of the file-level import graph — callers that want
|
|
56
|
+
* parallel per-SCC processing in the resolution phase read this. */
|
|
57
|
+
readonly sccs: readonly FinalizedScc[];
|
|
58
|
+
readonly stats: FinalizeStats;
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ScopeResolutionIndexes` — the bundle of materialized indexes produced
|
|
3
|
+
* by the finalize-orchestrator (RFC #909 Ring 2 PKG #921) and attached
|
|
4
|
+
* to `MutableSemanticModel`.
|
|
5
|
+
*
|
|
6
|
+
* Produced by `finalizeScopeModel(parsedFiles, hooks)` in
|
|
7
|
+
* `finalize-orchestrator.ts`. Consumed by the resolution phase (future
|
|
8
|
+
* tickets) where `Registry.lookup` / `resolveTypeRef` query this bundle
|
|
9
|
+
* to answer call-resolution questions without re-walking any AST.
|
|
10
|
+
*
|
|
11
|
+
* ## Lifecycle
|
|
12
|
+
*
|
|
13
|
+
* 1. Pipeline collects `ParsedFile[]` from the parsing-processor (#920).
|
|
14
|
+
* 2. Pipeline invokes `finalizeScopeModel(parsedFiles, hooks)` →
|
|
15
|
+
* returns a `ScopeResolutionIndexes` (this interface).
|
|
16
|
+
* 3. Pipeline calls `model.attachScopeIndexes(indexes)` to stamp them
|
|
17
|
+
* onto the `MutableSemanticModel`. This is a **one-shot write**;
|
|
18
|
+
* subsequent calls throw. After attachment, the indexes are frozen
|
|
19
|
+
* at the type level (everything is `readonly`) and at runtime via
|
|
20
|
+
* `Object.freeze` on the bundle.
|
|
21
|
+
* 4. Resolution callers hold a `SemanticModel` reference and read
|
|
22
|
+
* `model.scopes` to query.
|
|
23
|
+
*
|
|
24
|
+
* ## Content
|
|
25
|
+
*
|
|
26
|
+
* - `scopeTree` / `moduleScopes` / `defs` / `qualifiedNames` — the
|
|
27
|
+
* four Ring 2 SHARED indexes built over per-file artifacts.
|
|
28
|
+
* - `methodDispatch` — MRO + implements materialized view (#914).
|
|
29
|
+
* - `imports` — finalized `ImportEdge[]` per module scope (`parsedImports`
|
|
30
|
+
* resolved through cross-file link + wildcard expansion).
|
|
31
|
+
* - `bindings` — merged bindings per module scope (local + import +
|
|
32
|
+
* wildcard + re-export), with the provider's precedence applied.
|
|
33
|
+
* - `referenceSites` — union of every file's pre-resolution usage
|
|
34
|
+
* facts. Consumed by the resolution phase (future) to emit
|
|
35
|
+
* `Reference` records into `ReferenceIndex`.
|
|
36
|
+
* - `stats` — coarse-grained counts from the shared finalize algorithm
|
|
37
|
+
* (total files/edges, linked vs unresolved, SCC topology).
|
|
38
|
+
*
|
|
39
|
+
* `ReferenceIndex` is deliberately NOT here — it is populated in a later
|
|
40
|
+
* phase (RFC §3.2 Phase 4 / Ring 2 PKG #925) and owned separately.
|
|
41
|
+
*/
|
|
42
|
+
export {};
|
|
@@ -49,6 +49,7 @@ import type { TypeRegistry, MutableTypeRegistry } from './type-registry.js';
|
|
|
49
49
|
import type { MethodRegistry, MutableMethodRegistry } from './method-registry.js';
|
|
50
50
|
import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js';
|
|
51
51
|
import type { SymbolTableReader, SymbolTableWriter } from './symbol-table.js';
|
|
52
|
+
import type { ScopeResolutionIndexes } from './scope-resolution-indexes.js';
|
|
52
53
|
/**
|
|
53
54
|
* Aggregated read-only view of the semantic registries plus the nested
|
|
54
55
|
* file/callable SymbolTable.
|
|
@@ -70,6 +71,19 @@ export interface SemanticModel {
|
|
|
70
71
|
readonly methods: MethodRegistry;
|
|
71
72
|
readonly fields: FieldRegistry;
|
|
72
73
|
readonly symbols: SymbolTableReader;
|
|
74
|
+
/**
|
|
75
|
+
* Materialized scope-resolution indexes from RFC #909 Ring 2 PKG #921.
|
|
76
|
+
*
|
|
77
|
+
* `undefined` until the finalize-orchestrator attaches them. While
|
|
78
|
+
* `undefined`, the legacy DAG is the sole resolution surface; once set,
|
|
79
|
+
* resolvers whose language has `REGISTRY_PRIMARY_<LANG>=true` consult
|
|
80
|
+
* these indexes instead.
|
|
81
|
+
*
|
|
82
|
+
* The attach is a one-shot write (see `MutableSemanticModel`). Callers
|
|
83
|
+
* holding a read-only `SemanticModel` handle see either `undefined` or
|
|
84
|
+
* the final frozen bundle — never a half-populated view.
|
|
85
|
+
*/
|
|
86
|
+
readonly scopes?: ScopeResolutionIndexes;
|
|
73
87
|
}
|
|
74
88
|
/** Mutable variant — exposes the MutableX registries, a Writer-typed
|
|
75
89
|
* `symbols` facade, and a full-cascade reset. This is the interface
|
|
@@ -82,5 +96,16 @@ export interface MutableSemanticModel extends SemanticModel {
|
|
|
82
96
|
readonly symbols: SymbolTableWriter;
|
|
83
97
|
/** Clear all registries AND the nested SymbolTable. */
|
|
84
98
|
clear(): void;
|
|
99
|
+
/**
|
|
100
|
+
* Stamp the finalize-orchestrator's output onto this model.
|
|
101
|
+
*
|
|
102
|
+
* **One-shot write.** Throws when called a second time — the indexes are
|
|
103
|
+
* meant to be materialized once per ingestion run. `Object.freeze` is
|
|
104
|
+
* applied to the attached bundle so consumers cannot mutate after attach.
|
|
105
|
+
*
|
|
106
|
+
* `clear()` resets the attached bundle back to `undefined`, enabling a
|
|
107
|
+
* fresh re-ingestion to attach a new bundle.
|
|
108
|
+
*/
|
|
109
|
+
attachScopeIndexes(indexes: ScopeResolutionIndexes): void;
|
|
85
110
|
}
|
|
86
111
|
export declare const createSemanticModel: () => MutableSemanticModel;
|
|
@@ -85,6 +85,17 @@ export const createSemanticModel = () => {
|
|
|
85
85
|
}
|
|
86
86
|
return def;
|
|
87
87
|
};
|
|
88
|
+
// Scope-resolution bundle slot. Starts `undefined`; populated by a
|
|
89
|
+
// one-shot `attachScopeIndexes(...)` from the finalize-orchestrator.
|
|
90
|
+
// Held inside the factory closure so the returned `SemanticModel`
|
|
91
|
+
// surface exposes it as a plain `readonly` property without a setter.
|
|
92
|
+
let attachedScopes;
|
|
93
|
+
const attachScopeIndexes = (indexes) => {
|
|
94
|
+
if (attachedScopes !== undefined) {
|
|
95
|
+
throw new Error('SemanticModel: scope indexes already attached. ' + 'Call `clear()` before re-attaching.');
|
|
96
|
+
}
|
|
97
|
+
attachedScopes = Object.freeze(indexes);
|
|
98
|
+
};
|
|
88
99
|
// Cascade clear: single source of truth for "reset the entire model".
|
|
89
100
|
// Wired into both `model.clear()` AND `model.symbols.clear()` so that a
|
|
90
101
|
// caller holding only a SymbolTable reference can't leave the
|
|
@@ -95,6 +106,7 @@ export const createSemanticModel = () => {
|
|
|
95
106
|
methods.clear();
|
|
96
107
|
fields.clear();
|
|
97
108
|
rawSymbols.clear();
|
|
109
|
+
attachedScopes = undefined;
|
|
98
110
|
};
|
|
99
111
|
// Writer-typed facade: exposes reads + add, but NO `clear` field.
|
|
100
112
|
// Callers holding a `SemanticModel.symbols` reference cannot desync
|
|
@@ -115,6 +127,10 @@ export const createSemanticModel = () => {
|
|
|
115
127
|
methods,
|
|
116
128
|
fields,
|
|
117
129
|
symbols,
|
|
130
|
+
get scopes() {
|
|
131
|
+
return attachedScopes;
|
|
132
|
+
},
|
|
118
133
|
clear: cascadeClear,
|
|
134
|
+
attachScopeIndexes,
|
|
119
135
|
};
|
|
120
136
|
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow-mode parity harness — dual-run observability for the RFC #909
|
|
3
|
+
* registry rollout (RFC §6.3; Ring 2 PKG #923).
|
|
4
|
+
*
|
|
5
|
+
* ## What it does
|
|
6
|
+
*
|
|
7
|
+
* - Exposes `record({ language, callsite, legacy, newResult })` for
|
|
8
|
+
* every call site where the caller has BOTH a legacy-DAG resolution
|
|
9
|
+
* and a new `Registry.lookup` resolution.
|
|
10
|
+
* - Computes a `ShadowDiff` per record via shared `diffResolutions`
|
|
11
|
+
* (#918) and accumulates them in a per-language bucket.
|
|
12
|
+
* - At the end of a run, aggregates into a `ShadowParityReport` via
|
|
13
|
+
* shared `aggregateDiffs` (#918) — per-language parity %,
|
|
14
|
+
* evidence-kind breakdown of divergences, grand-total overall row.
|
|
15
|
+
* - Optionally persists the report as JSON under
|
|
16
|
+
* `.gitnexus/shadow-parity/` so the static dashboard at
|
|
17
|
+
* `gitnexus/shadow-parity-dashboard/` can render it offline.
|
|
18
|
+
*
|
|
19
|
+
* ## What it does NOT do
|
|
20
|
+
*
|
|
21
|
+
* - **Invoke either resolution path itself.** The caller must run
|
|
22
|
+
* legacy + `Registry.lookup` and pass results in. The harness is a
|
|
23
|
+
* side-car, not a dispatcher — this keeps call-processor integration
|
|
24
|
+
* surgical when it lands (tracked as a follow-up; the shared model
|
|
25
|
+
* doesn't dual-invoke on its own).
|
|
26
|
+
* - **Flip anything.** `REGISTRY_PRIMARY_<LANG>` lives in
|
|
27
|
+
* `registry-primary-flag.ts` (#924); the harness records the
|
|
28
|
+
* caller-supplied "which side is primary" bit for each record so the
|
|
29
|
+
* dashboard can label rows, but it does not consult the flag itself.
|
|
30
|
+
*
|
|
31
|
+
* ## Activation
|
|
32
|
+
*
|
|
33
|
+
* `GITNEXUS_SHADOW_MODE=1` (or `'true'`, `'yes'`, case-insensitive,
|
|
34
|
+
* trimmed) enables the harness. When disabled, `record()` is a cheap
|
|
35
|
+
* no-op: no accumulation, no allocation beyond the harness object
|
|
36
|
+
* itself. Callers can always construct a harness and hand it through;
|
|
37
|
+
* the "off" overhead is near-zero.
|
|
38
|
+
*
|
|
39
|
+
* ## Persistence shape
|
|
40
|
+
*
|
|
41
|
+
* When `persist()` is called, the harness writes TWO files:
|
|
42
|
+
*
|
|
43
|
+
* - `<outputDir>/<runId>.json` — the timestamped snapshot (immutable)
|
|
44
|
+
* - `<outputDir>/latest.json` — a pointer that the dashboard reads
|
|
45
|
+
*
|
|
46
|
+
* Both files contain the same `PersistedShadowReport` payload:
|
|
47
|
+
*
|
|
48
|
+
* {
|
|
49
|
+
* schemaVersion: 1,
|
|
50
|
+
* runId: "<iso-8601>-<rand>",
|
|
51
|
+
* generatedAt: "<iso-8601>",
|
|
52
|
+
* primaryByLanguage: { [lang]: "legacy" | "registry" },
|
|
53
|
+
* report: <ShadowParityReport>
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* Schema-version-gated so future format changes don't silently confuse
|
|
57
|
+
* older dashboards. The dashboard renders `report.perLanguage` rows and
|
|
58
|
+
* annotates each with `primaryByLanguage[lang]`.
|
|
59
|
+
*/
|
|
60
|
+
import { type Resolution, type ShadowCallsite, type ShadowParityReport, type SupportedLanguages } from '../../_shared/index.js';
|
|
61
|
+
/** Which side of the dual-run is considered authoritative for this language. */
|
|
62
|
+
export type PrimarySide = 'legacy' | 'registry';
|
|
63
|
+
/** One record per call site the caller dual-runs. */
|
|
64
|
+
export interface ShadowRecordInput {
|
|
65
|
+
readonly language: SupportedLanguages;
|
|
66
|
+
readonly callsite: ShadowCallsite;
|
|
67
|
+
readonly legacy: readonly Resolution[];
|
|
68
|
+
readonly newResult: readonly Resolution[];
|
|
69
|
+
/**
|
|
70
|
+
* Which side drove the actual runtime answer for this record. Lets the
|
|
71
|
+
* dashboard distinguish "registry-primary, legacy is shadow" from the
|
|
72
|
+
* default "legacy-primary, registry is shadow" without re-reading
|
|
73
|
+
* `REGISTRY_PRIMARY_<LANG>` env vars at render time.
|
|
74
|
+
*/
|
|
75
|
+
readonly primary: PrimarySide;
|
|
76
|
+
}
|
|
77
|
+
/** Persisted JSON shape. Schema-versioned for future migrations. */
|
|
78
|
+
export interface PersistedShadowReport {
|
|
79
|
+
readonly schemaVersion: 1;
|
|
80
|
+
readonly runId: string;
|
|
81
|
+
readonly generatedAt: string;
|
|
82
|
+
readonly primaryByLanguage: Readonly<Partial<Record<SupportedLanguages, PrimarySide>>>;
|
|
83
|
+
readonly report: ShadowParityReport;
|
|
84
|
+
}
|
|
85
|
+
export interface ShadowHarness {
|
|
86
|
+
/** `true` iff `GITNEXUS_SHADOW_MODE` is truthy. When `false`, `record()` is a no-op. */
|
|
87
|
+
readonly enabled: boolean;
|
|
88
|
+
/** Accumulate a dual-run observation. No-op when `enabled === false`. */
|
|
89
|
+
record(input: ShadowRecordInput): void;
|
|
90
|
+
/** Number of records accumulated so far. Useful for diagnostics / tests. */
|
|
91
|
+
size(): number;
|
|
92
|
+
/**
|
|
93
|
+
* Aggregate the accumulated records into a `ShadowParityReport`
|
|
94
|
+
* without persisting. Returns a deterministic snapshot each call;
|
|
95
|
+
* idempotent with respect to `record()` ordering.
|
|
96
|
+
*/
|
|
97
|
+
snapshot(now?: Date): ShadowParityReport;
|
|
98
|
+
/**
|
|
99
|
+
* Write the aggregated snapshot to JSON. Resolves to the path of the
|
|
100
|
+
* per-run file. Also writes/overwrites `latest.json` alongside.
|
|
101
|
+
*
|
|
102
|
+
* Creates `outputDir` if it doesn't exist.
|
|
103
|
+
*/
|
|
104
|
+
persist(outputDir: string, now?: Date): Promise<string>;
|
|
105
|
+
/** Reset the accumulator. Preserves `enabled`. */
|
|
106
|
+
clear(): void;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Construct a harness. Reads `GITNEXUS_SHADOW_MODE` at construction time
|
|
110
|
+
* (not per-`record()` call) so repeated no-op records don't re-check the
|
|
111
|
+
* env var in the hot path.
|
|
112
|
+
*/
|
|
113
|
+
export declare function createShadowHarness(): ShadowHarness;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow-mode parity harness — dual-run observability for the RFC #909
|
|
3
|
+
* registry rollout (RFC §6.3; Ring 2 PKG #923).
|
|
4
|
+
*
|
|
5
|
+
* ## What it does
|
|
6
|
+
*
|
|
7
|
+
* - Exposes `record({ language, callsite, legacy, newResult })` for
|
|
8
|
+
* every call site where the caller has BOTH a legacy-DAG resolution
|
|
9
|
+
* and a new `Registry.lookup` resolution.
|
|
10
|
+
* - Computes a `ShadowDiff` per record via shared `diffResolutions`
|
|
11
|
+
* (#918) and accumulates them in a per-language bucket.
|
|
12
|
+
* - At the end of a run, aggregates into a `ShadowParityReport` via
|
|
13
|
+
* shared `aggregateDiffs` (#918) — per-language parity %,
|
|
14
|
+
* evidence-kind breakdown of divergences, grand-total overall row.
|
|
15
|
+
* - Optionally persists the report as JSON under
|
|
16
|
+
* `.gitnexus/shadow-parity/` so the static dashboard at
|
|
17
|
+
* `gitnexus/shadow-parity-dashboard/` can render it offline.
|
|
18
|
+
*
|
|
19
|
+
* ## What it does NOT do
|
|
20
|
+
*
|
|
21
|
+
* - **Invoke either resolution path itself.** The caller must run
|
|
22
|
+
* legacy + `Registry.lookup` and pass results in. The harness is a
|
|
23
|
+
* side-car, not a dispatcher — this keeps call-processor integration
|
|
24
|
+
* surgical when it lands (tracked as a follow-up; the shared model
|
|
25
|
+
* doesn't dual-invoke on its own).
|
|
26
|
+
* - **Flip anything.** `REGISTRY_PRIMARY_<LANG>` lives in
|
|
27
|
+
* `registry-primary-flag.ts` (#924); the harness records the
|
|
28
|
+
* caller-supplied "which side is primary" bit for each record so the
|
|
29
|
+
* dashboard can label rows, but it does not consult the flag itself.
|
|
30
|
+
*
|
|
31
|
+
* ## Activation
|
|
32
|
+
*
|
|
33
|
+
* `GITNEXUS_SHADOW_MODE=1` (or `'true'`, `'yes'`, case-insensitive,
|
|
34
|
+
* trimmed) enables the harness. When disabled, `record()` is a cheap
|
|
35
|
+
* no-op: no accumulation, no allocation beyond the harness object
|
|
36
|
+
* itself. Callers can always construct a harness and hand it through;
|
|
37
|
+
* the "off" overhead is near-zero.
|
|
38
|
+
*
|
|
39
|
+
* ## Persistence shape
|
|
40
|
+
*
|
|
41
|
+
* When `persist()` is called, the harness writes TWO files:
|
|
42
|
+
*
|
|
43
|
+
* - `<outputDir>/<runId>.json` — the timestamped snapshot (immutable)
|
|
44
|
+
* - `<outputDir>/latest.json` — a pointer that the dashboard reads
|
|
45
|
+
*
|
|
46
|
+
* Both files contain the same `PersistedShadowReport` payload:
|
|
47
|
+
*
|
|
48
|
+
* {
|
|
49
|
+
* schemaVersion: 1,
|
|
50
|
+
* runId: "<iso-8601>-<rand>",
|
|
51
|
+
* generatedAt: "<iso-8601>",
|
|
52
|
+
* primaryByLanguage: { [lang]: "legacy" | "registry" },
|
|
53
|
+
* report: <ShadowParityReport>
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* Schema-version-gated so future format changes don't silently confuse
|
|
57
|
+
* older dashboards. The dashboard renders `report.perLanguage` rows and
|
|
58
|
+
* annotates each with `primaryByLanguage[lang]`.
|
|
59
|
+
*/
|
|
60
|
+
import * as fs from 'node:fs/promises';
|
|
61
|
+
import * as path from 'node:path';
|
|
62
|
+
import { aggregateDiffs, diffResolutions, } from '../../_shared/index.js';
|
|
63
|
+
/**
|
|
64
|
+
* Construct a harness. Reads `GITNEXUS_SHADOW_MODE` at construction time
|
|
65
|
+
* (not per-`record()` call) so repeated no-op records don't re-check the
|
|
66
|
+
* env var in the hot path.
|
|
67
|
+
*/
|
|
68
|
+
export function createShadowHarness() {
|
|
69
|
+
const enabled = parseShadowModeEnv(process.env['GITNEXUS_SHADOW_MODE']);
|
|
70
|
+
const records = [];
|
|
71
|
+
const primaryByLanguage = {};
|
|
72
|
+
const recordImpl = (input) => {
|
|
73
|
+
if (!enabled)
|
|
74
|
+
return;
|
|
75
|
+
const diff = diffResolutions(input.callsite, input.legacy, input.newResult);
|
|
76
|
+
records.push({ language: input.language, diff });
|
|
77
|
+
// Primary per-language is resolved by last-write. In practice a run
|
|
78
|
+
// is single-threaded with respect to flag readings, so this is
|
|
79
|
+
// deterministic; a language's primary cannot change mid-run.
|
|
80
|
+
primaryByLanguage[input.language] = input.primary;
|
|
81
|
+
};
|
|
82
|
+
const snapshotImpl = (now = new Date()) => {
|
|
83
|
+
return aggregateDiffs(records, now);
|
|
84
|
+
};
|
|
85
|
+
const persistImpl = async (outputDir, now = new Date()) => {
|
|
86
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
87
|
+
const report = snapshotImpl(now);
|
|
88
|
+
const runId = makeRunId(now);
|
|
89
|
+
const payload = {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
runId,
|
|
92
|
+
generatedAt: now.toISOString(),
|
|
93
|
+
primaryByLanguage,
|
|
94
|
+
report,
|
|
95
|
+
};
|
|
96
|
+
const json = JSON.stringify(payload, null, 2);
|
|
97
|
+
const perRunPath = path.join(outputDir, `${runId}.json`);
|
|
98
|
+
const latestPath = path.join(outputDir, 'latest.json');
|
|
99
|
+
await fs.writeFile(perRunPath, json, 'utf8');
|
|
100
|
+
await fs.writeFile(latestPath, json, 'utf8');
|
|
101
|
+
return perRunPath;
|
|
102
|
+
};
|
|
103
|
+
const clearImpl = () => {
|
|
104
|
+
records.length = 0;
|
|
105
|
+
for (const key of Object.keys(primaryByLanguage)) {
|
|
106
|
+
delete primaryByLanguage[key];
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
enabled,
|
|
111
|
+
record: recordImpl,
|
|
112
|
+
size: () => records.length,
|
|
113
|
+
snapshot: snapshotImpl,
|
|
114
|
+
persist: persistImpl,
|
|
115
|
+
clear: clearImpl,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// ─── Internal helpers ─────────────────────────────────────────────────────
|
|
119
|
+
/**
|
|
120
|
+
* Env-var parser for `GITNEXUS_SHADOW_MODE`. Accepts the same truthy
|
|
121
|
+
* conventions as `REGISTRY_PRIMARY_<LANG>` from #924: `'true'` / `'1'` /
|
|
122
|
+
* `'yes'`, case-insensitive, whitespace-trimmed. Anything else — including
|
|
123
|
+
* `undefined`, `''`, `'false'`, `'off'`, typos — is false.
|
|
124
|
+
*/
|
|
125
|
+
function parseShadowModeEnv(raw) {
|
|
126
|
+
if (raw === undefined)
|
|
127
|
+
return false;
|
|
128
|
+
const normalized = raw.trim().toLowerCase();
|
|
129
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes';
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Deterministic run id derived from the timestamp plus 4 random bytes
|
|
133
|
+
* of entropy. The timestamp comes first so files sort chronologically;
|
|
134
|
+
* the entropy suffix prevents collisions when multiple runs share a
|
|
135
|
+
* clock-second. Shape: `YYYYMMDD-HHMMSS-xxxxxxxx`.
|
|
136
|
+
*/
|
|
137
|
+
function makeRunId(now) {
|
|
138
|
+
const y = now.getUTCFullYear().toString().padStart(4, '0');
|
|
139
|
+
const m = (now.getUTCMonth() + 1).toString().padStart(2, '0');
|
|
140
|
+
const d = now.getUTCDate().toString().padStart(2, '0');
|
|
141
|
+
const h = now.getUTCHours().toString().padStart(2, '0');
|
|
142
|
+
const min = now.getUTCMinutes().toString().padStart(2, '0');
|
|
143
|
+
const s = now.getUTCSeconds().toString().padStart(2, '0');
|
|
144
|
+
const entropy = Math.floor(Math.random() * 0xffffffff)
|
|
145
|
+
.toString(16)
|
|
146
|
+
.padStart(8, '0');
|
|
147
|
+
return `${y}${m}${d}-${h}${min}${s}-${entropy}`;
|
|
148
|
+
}
|
package/package.json
CHANGED