gitnexus 1.6.10-rc.151 → 1.6.10-rc.152
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/analyzer-identity.d.ts +18 -0
- package/dist/core/analyzer-identity.js +280 -20
- package/dist/core/lbug/schema.d.ts +108 -0
- package/dist/core/lbug/schema.js +114 -0
- package/dist/core/run-analyze.js +108 -30
- package/dist/mcp/local/local-backend.d.ts +26 -0
- package/dist/mcp/local/local-backend.js +99 -3
- package/dist/mcp/local/pdg-impact.d.ts +1 -1
- package/dist/mcp/local/pdg-impact.js +1 -1
- package/dist/storage/parse-cache.js +3 -1
- package/dist/storage/repo-manager.d.ts +58 -294
- package/dist/storage/repo-manager.js +0 -283
- package/package.json +1 -1
- package/skills/gitnexus-review/SKILL.md +11 -4
|
@@ -109,6 +109,24 @@ export declare function normalizeAnalyzerRootPath(p: string, platform: NodeJS.Pl
|
|
|
109
109
|
declare function isInside(parent: string, candidate: string, pathApi?: typeof path): boolean;
|
|
110
110
|
/** Test seam for {@link isInside} (see `_hashAnalyzerIdentityFramesForTests`). */
|
|
111
111
|
export declare const _isInsideForTests: typeof isInside;
|
|
112
|
+
/**
|
|
113
|
+
* Whether a REALPATH'd package root lives inside some installed dependency
|
|
114
|
+
* tree. Used as the resolved-location half of "is this dependency a checkout
|
|
115
|
+
* this repository owns?" (see {@link undeclaredLocalDevDependencyNames}).
|
|
116
|
+
*
|
|
117
|
+
* The input must already be realpath'd: `resolveDependencyPackageRoot` returns
|
|
118
|
+
* `realpathSync.native`, so a package reached through a link out of
|
|
119
|
+
* `node_modules` reports its checkout location and a package that merely lives
|
|
120
|
+
* in `node_modules` reports a path that still carries the segment.
|
|
121
|
+
*
|
|
122
|
+
* `pathApi` is injectable so the Windows separator handling is unit-testable
|
|
123
|
+
* from a POSIX runner, exactly as {@link isInside} does. The separator sets
|
|
124
|
+
* differ deliberately: `\` is a legal filename character on POSIX, so only
|
|
125
|
+
* win32 may treat it as a boundary.
|
|
126
|
+
*/
|
|
127
|
+
declare function hasNodeModulesSegment(candidate: string, pathApi?: typeof path): boolean;
|
|
128
|
+
/** Test seam for {@link hasNodeModulesSegment} (see {@link _isInsideForTests}). */
|
|
129
|
+
export declare const _hasNodeModulesSegmentForTests: typeof hasNodeModulesSegment;
|
|
112
130
|
export type AnalyzerRunnerSemanticIdentity = Omit<AnalyzerRunnerIdentity, 'invokedArtifact'>;
|
|
113
131
|
/**
|
|
114
132
|
* Normalize a raw diagnostic receipt for freshness comparison. The entrypoint
|
|
@@ -154,6 +154,24 @@ function snapshotReadableFile(candidate) {
|
|
|
154
154
|
...(link.isSymbolicLink() ? { symlinkTarget: readlinkSync(candidate) } : {}),
|
|
155
155
|
};
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Snapshot a symbolic link without resolving it. Unlike
|
|
159
|
+
* {@link snapshotReadableFile} this never stats the target, so it is total over
|
|
160
|
+
* linked directories, dangling links, and links to device nodes — the inputs
|
|
161
|
+
* that make the readable-file snapshot throw.
|
|
162
|
+
*/
|
|
163
|
+
function snapshotSymlinkArtifact(candidate) {
|
|
164
|
+
const link = lstatSync(candidate, { bigint: true });
|
|
165
|
+
if (!link.isSymbolicLink()) {
|
|
166
|
+
throw new Error(`Analyzer identity input is not a symbolic link: ${candidate}`);
|
|
167
|
+
}
|
|
168
|
+
return { link: statState(link), symlinkTarget: readlinkSync(candidate) };
|
|
169
|
+
}
|
|
170
|
+
function snapshotRuntimeArtifact(artifact) {
|
|
171
|
+
return artifact.kind === 'unfollowed-symlink'
|
|
172
|
+
? snapshotSymlinkArtifact(artifact.absolutePath)
|
|
173
|
+
: snapshotReadableFile(artifact.absolutePath);
|
|
174
|
+
}
|
|
157
175
|
function snapshotDirectory(candidate) {
|
|
158
176
|
const stat = lstatSync(candidate, { bigint: true });
|
|
159
177
|
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
@@ -597,6 +615,96 @@ function runtimePackageLocator(packageRoot, runtimeRoot) {
|
|
|
597
615
|
const relative = path.relative(packageRoot, runtimeRoot).split(path.sep).join('/');
|
|
598
616
|
return `relative:${relative}`;
|
|
599
617
|
}
|
|
618
|
+
/** Protocols that name a checkout-local package instead of a registry tarball. */
|
|
619
|
+
const LOCAL_LINK_PROTOCOL_PATTERN = /^(?:file|link|workspace|portal):/;
|
|
620
|
+
/** npm's bare local-path shorthands: `./x`, `../x`, `/x`, `~/x`, `C:\x`. */
|
|
621
|
+
const LOCAL_LINK_PATH_PATTERN = /^(?:\.\.?[/\\]|~[/\\]|[/\\]|[A-Za-z]:)/;
|
|
622
|
+
function isLocallyLinkedSpecifier(specifier) {
|
|
623
|
+
if (typeof specifier !== 'string')
|
|
624
|
+
return false;
|
|
625
|
+
const value = specifier.trim();
|
|
626
|
+
return LOCAL_LINK_PROTOCOL_PATTERN.test(value) || LOCAL_LINK_PATH_PATTERN.test(value);
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Whether a REALPATH'd package root lives inside some installed dependency
|
|
630
|
+
* tree. Used as the resolved-location half of "is this dependency a checkout
|
|
631
|
+
* this repository owns?" (see {@link undeclaredLocalDevDependencyNames}).
|
|
632
|
+
*
|
|
633
|
+
* The input must already be realpath'd: `resolveDependencyPackageRoot` returns
|
|
634
|
+
* `realpathSync.native`, so a package reached through a link out of
|
|
635
|
+
* `node_modules` reports its checkout location and a package that merely lives
|
|
636
|
+
* in `node_modules` reports a path that still carries the segment.
|
|
637
|
+
*
|
|
638
|
+
* `pathApi` is injectable so the Windows separator handling is unit-testable
|
|
639
|
+
* from a POSIX runner, exactly as {@link isInside} does. The separator sets
|
|
640
|
+
* differ deliberately: `\` is a legal filename character on POSIX, so only
|
|
641
|
+
* win32 may treat it as a boundary.
|
|
642
|
+
*/
|
|
643
|
+
function hasNodeModulesSegment(candidate, pathApi = path) {
|
|
644
|
+
const segments = pathApi.sep === '\\' ? candidate.split(/[\\/]+/) : candidate.split('/');
|
|
645
|
+
return segments.includes('node_modules');
|
|
646
|
+
}
|
|
647
|
+
/** Test seam for {@link hasNodeModulesSegment} (see {@link _isInsideForTests}). */
|
|
648
|
+
export const _hasNodeModulesSegmentForTests = hasNodeModulesSegment;
|
|
649
|
+
/**
|
|
650
|
+
* How many dev dependencies may be admitted by RESOLVED LOCATION alone before
|
|
651
|
+
* the whole resolved-location channel is treated as untrustworthy and disabled.
|
|
652
|
+
*
|
|
653
|
+
* "Realpath carries no `node_modules` segment" is a proxy for "checkout-local",
|
|
654
|
+
* and a layout that materializes packages outside `node_modules` — pnpm with a
|
|
655
|
+
* relocated `virtual-store-dir`, a custom linker — makes every dev dependency
|
|
656
|
+
* pass it. Folding an entire dev tree into the receipt is not a graceful
|
|
657
|
+
* degradation: `runtimePackages`/`runtimeEntries`/`runtimeBytes` THROW, so a
|
|
658
|
+
* mis-fired proxy on a legitimate install would abort analyze outright.
|
|
659
|
+
*
|
|
660
|
+
* The bound is therefore on ADMISSIONS, and overflow admits NONE of them rather
|
|
661
|
+
* than an arbitrary prefix. A prefix would not bound the failure — the abort
|
|
662
|
+
* comes from the transitive payload of whichever trees get folded in — and it
|
|
663
|
+
* would make the receipt depend on an arbitrary slice of a sorted name list.
|
|
664
|
+
* Dropping the channel wholesale falls back to the specifier-only receipt,
|
|
665
|
+
* which is the behaviour that ships today and is known not to abort, and leaves
|
|
666
|
+
* the declared-intent half in {@link dependencyNames} untouched.
|
|
667
|
+
*
|
|
668
|
+
* Four is measured, not guessed. Monorepo and workspace links are declared
|
|
669
|
+
* (`file:`/`link:`/`workspace:`) and travel the uncapped declared half, so this
|
|
670
|
+
* channel only ever carries UNDECLARED `npm link <pkg>` — a manual, per-package
|
|
671
|
+
* developer action, in practice one or two packages. A mis-fire admits the
|
|
672
|
+
* entire dev-only set instead: 13 names in this repository's own install, tens
|
|
673
|
+
* in a typical application. The cap sits an order of magnitude below the
|
|
674
|
+
* mis-fire population and comfortably above realistic link counts.
|
|
675
|
+
*/
|
|
676
|
+
const MAX_UNDECLARED_LOCAL_DEV_DEPENDENCIES = 4;
|
|
677
|
+
/**
|
|
678
|
+
* Dependency names whose resolved packages can contribute analyzer semantics.
|
|
679
|
+
*
|
|
680
|
+
* The three runtime sections are enumerated wholesale. `devDependencies` are
|
|
681
|
+
* deliberately not: a registry dev tool (vitest, eslint, typescript) is never
|
|
682
|
+
* loaded by the analyzer, and folding the dev tree into the receipt would churn
|
|
683
|
+
* `dependencyRuntime.digest` — and force a full re-analysis — on every unrelated
|
|
684
|
+
* devDependency bump.
|
|
685
|
+
*
|
|
686
|
+
* Locally linked dev dependencies are the exception. A `file:`/`link:`/
|
|
687
|
+
* `workspace:` sibling is part of this checkout and ships code the analyzer
|
|
688
|
+
* imports at runtime: GitNexus links `gitnexus-shared`, whose schema constants
|
|
689
|
+
* feed `RELATION_SCHEMA`/`NODE_SCHEMA_QUERIES`. In `kind: 'source'` runs that
|
|
690
|
+
* sibling sits outside `buildRoot`, so leaving it out let a semantic change
|
|
691
|
+
* there alter analyzer behaviour while moving neither `build.digest` nor
|
|
692
|
+
* `dependencyRuntime.digest` — DDL-affecting edits were still caught by the
|
|
693
|
+
* schema fingerprint, semantics-only edits by nothing.
|
|
694
|
+
*
|
|
695
|
+
* An unresolvable link (a published install, where the sibling checkout does not
|
|
696
|
+
* exist) still contributes its `<missing>` edge, so the linked package appearing
|
|
697
|
+
* or disappearing remains a receipt change rather than a silent one. That is why
|
|
698
|
+
* the specifier check cannot be replaced by resolution: resolution returns
|
|
699
|
+
* `null` for an absent linked checkout exactly as it does for an uninstalled
|
|
700
|
+
* registry dev tool, and the two must not be conflated.
|
|
701
|
+
*
|
|
702
|
+
* This function is the DECLARED-INTENT half and is enumerated for every package
|
|
703
|
+
* in the dependency BFS, so it must stay a pure function of the manifest. The
|
|
704
|
+
* RESOLVED-LOCATION half — `npm link <pkg>`, which leaves the specifier a
|
|
705
|
+
* registry range — lives in {@link undeclaredLocalDevDependencyNames} and is
|
|
706
|
+
* applied to the root package only.
|
|
707
|
+
*/
|
|
600
708
|
function dependencyNames(manifest) {
|
|
601
709
|
const names = new Set();
|
|
602
710
|
for (const section of [
|
|
@@ -609,6 +717,13 @@ function dependencyNames(manifest) {
|
|
|
609
717
|
for (const name of Object.keys(section))
|
|
610
718
|
names.add(name);
|
|
611
719
|
}
|
|
720
|
+
const development = manifest.devDependencies;
|
|
721
|
+
if (development && typeof development === 'object') {
|
|
722
|
+
for (const [name, specifier] of Object.entries(development)) {
|
|
723
|
+
if (isLocallyLinkedSpecifier(specifier))
|
|
724
|
+
names.add(name);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
612
727
|
return [...names].sort(compareBytes);
|
|
613
728
|
}
|
|
614
729
|
function resolveDependencyPackageRoot(fromRoot, packageName, pathGuards, limits) {
|
|
@@ -641,6 +756,51 @@ function resolveDependencyPackageRoot(fromRoot, packageName, pathGuards, limits)
|
|
|
641
756
|
cursor = parent;
|
|
642
757
|
}
|
|
643
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Dev dependencies that are locally linked by INSTALLED LOCATION rather than by
|
|
761
|
+
* declared specifier — the `npm link <pkg>` shape, where the manifest still
|
|
762
|
+
* carries a registry range while `node_modules/<pkg>` is a symlink into a
|
|
763
|
+
* working checkout. {@link isLocallyLinkedSpecifier} is blind to those, yet the
|
|
764
|
+
* linked code is exactly as load-bearing for analyzer semantics as a declared
|
|
765
|
+
* `file:` sibling, so a semantic-only edit there would move neither digest.
|
|
766
|
+
*
|
|
767
|
+
* The resolver already knows: {@link resolveDependencyPackageRoot} returns a
|
|
768
|
+
* realpath, so a linked package reports a root outside every `node_modules`
|
|
769
|
+
* tree while an ordinary installed package cannot.
|
|
770
|
+
*
|
|
771
|
+
* Two properties are load-bearing and must not be relaxed:
|
|
772
|
+
*
|
|
773
|
+
* 1. ROOT ONLY. {@link dependencyNames} runs for every package in the BFS, and
|
|
774
|
+
* published tarballs keep their `devDependencies`, so probing dev-only names
|
|
775
|
+
* everywhere costs 1998 resolutions rather than the ~13 this manifest
|
|
776
|
+
* declares — measured on this install, with 0 true positives. Persisted
|
|
777
|
+
* `dependencyPathGuards` grow 2220 → 11049, and every guard is re-probed on
|
|
778
|
+
* each warm validation, so the cost is recurring and on the `status` path.
|
|
779
|
+
* Root-only costs 13 resolutions and ~29 guards.
|
|
780
|
+
* 2. An unresolvable name is NEVER admitted. `null` here means "uninstalled
|
|
781
|
+
* registry dev tool" far more often than "broken link", and admitting it
|
|
782
|
+
* would emit a `<missing>` edge for every dev tool absent from a published
|
|
783
|
+
* install. Declared links keep that edge through {@link dependencyNames};
|
|
784
|
+
* undeclared ones have no declaration to honour.
|
|
785
|
+
*
|
|
786
|
+
* The admission count is bounded by {@link MAX_UNDECLARED_LOCAL_DEV_DEPENDENCIES}.
|
|
787
|
+
*/
|
|
788
|
+
function undeclaredLocalDevDependencyNames(rootPackage, pathGuards, limits) {
|
|
789
|
+
const development = rootPackage.manifest.devDependencies;
|
|
790
|
+
if (!development || typeof development !== 'object')
|
|
791
|
+
return [];
|
|
792
|
+
const admitted = [];
|
|
793
|
+
for (const [name, specifier] of Object.entries(development)) {
|
|
794
|
+
// Already carried by the declared half; resolving again would only add
|
|
795
|
+
// guards. Its `<missing>` edge is that half's responsibility.
|
|
796
|
+
if (isLocallyLinkedSpecifier(specifier))
|
|
797
|
+
continue;
|
|
798
|
+
const resolved = resolveDependencyPackageRoot(rootPackage.root, name, pathGuards, limits);
|
|
799
|
+
if (resolved !== null && !hasNodeModulesSegment(resolved))
|
|
800
|
+
admitted.push(name);
|
|
801
|
+
}
|
|
802
|
+
return admitted.length <= MAX_UNDECLARED_LOCAL_DEV_DEPENDENCIES ? admitted : [];
|
|
803
|
+
}
|
|
644
804
|
function collectRuntimePackages(packageRoot, directoryGuards, pathGuards, options, budget, limits) {
|
|
645
805
|
const rootManifestPath = path.join(packageRoot, 'package.json');
|
|
646
806
|
recordDirectoryGuard(directoryGuards, packageRoot);
|
|
@@ -660,7 +820,20 @@ function collectRuntimePackages(packageRoot, directoryGuards, pathGuards, option
|
|
|
660
820
|
budget.packages = 1;
|
|
661
821
|
for (let index = 0; index < queue.length; index += 1) {
|
|
662
822
|
const parent = queue[index];
|
|
663
|
-
for
|
|
823
|
+
// The declared half is enumerated for every package; the resolved-location
|
|
824
|
+
// half is scoped to the root package, where the 1998-resolution /
|
|
825
|
+
// 8829-extra-guard blow-up documented on
|
|
826
|
+
// `undeclaredLocalDevDependencyNames` cannot occur. Dropping this scope is
|
|
827
|
+
// the expensive regression, so it is pinned by a guard-count test.
|
|
828
|
+
const dependencies = parent.root === packageRoot
|
|
829
|
+
? [
|
|
830
|
+
...new Set([
|
|
831
|
+
...dependencyNames(parent.manifest),
|
|
832
|
+
...undeclaredLocalDevDependencyNames(parent, pathGuards, limits),
|
|
833
|
+
]),
|
|
834
|
+
].sort(compareBytes)
|
|
835
|
+
: dependencyNames(parent.manifest);
|
|
836
|
+
for (const dependencyName of dependencies) {
|
|
664
837
|
budget.edges += 1;
|
|
665
838
|
if (budget.edges > limits.runtimeEdges) {
|
|
666
839
|
throw new Error(`Analyzer dependency graph exceeded ${limits.runtimeEdges} edges: ${packageRoot}`);
|
|
@@ -736,22 +909,63 @@ function collectArtifacts(root, canonicalPrefix, directoryGuards, options, budge
|
|
|
736
909
|
throw new Error(`Analyzer runtime payload scan exceeded ${limits.runtimeEntries} entries: ${root}`);
|
|
737
910
|
}
|
|
738
911
|
for (const entry of entries) {
|
|
912
|
+
const absolutePath = path.join(absoluteDir, entry.name);
|
|
913
|
+
const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
|
|
914
|
+
const stat = lstatSync(absolutePath);
|
|
739
915
|
// Nested dependencies are collected from their manifests as separate
|
|
740
916
|
// packages. Only prune those separately traversed trees and VCS
|
|
741
917
|
// metadata; generic cache/model directories can contain loadable code,
|
|
742
918
|
// native addons, Wasm modules, or data consumed by the runtime.
|
|
743
|
-
|
|
919
|
+
//
|
|
920
|
+
// Pruning is decided by NAME alone. These four names never carry analyzer
|
|
921
|
+
// payload in any form: `node_modules` is traversed separately through
|
|
922
|
+
// `resolveDependencyPackageRoot` (which follows links and guards each
|
|
923
|
+
// hop), and a `.git`/`.hg`/`.svn` entry is VCS metadata whether it is a
|
|
924
|
+
// directory, a symbolic link into a shared store, or — inside a submodule
|
|
925
|
+
// or linked worktree checkout — a regular file holding a gitdir pointer.
|
|
926
|
+
// Hashing that pointer would make analyzer identity depend on where the
|
|
927
|
+
// checkout happens to live, which is a false-stale source, not a
|
|
928
|
+
// semantic input.
|
|
929
|
+
if (PRUNED_RUNTIME_DIRECTORIES.has(entry.name))
|
|
744
930
|
continue;
|
|
745
|
-
}
|
|
746
|
-
const absolutePath = path.join(absoluteDir, entry.name);
|
|
747
|
-
const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
|
|
748
|
-
const stat = lstatSync(absolutePath);
|
|
749
931
|
if (stat.isDirectory()) {
|
|
750
932
|
if (depth >= limits.runtimeDepth) {
|
|
751
933
|
throw new Error(`Analyzer runtime payload scan exceeded depth ${limits.runtimeDepth}: ${absolutePath}`);
|
|
752
934
|
}
|
|
753
935
|
pending.push({ absoluteDir: absolutePath, depth: depth + 1 });
|
|
754
936
|
}
|
|
937
|
+
else if (stat.isSymbolicLink() && !isFile(absolutePath)) {
|
|
938
|
+
// A symbolic link that does not resolve to a regular file must never
|
|
939
|
+
// reach the payload branch below: `snapshotReadableFile` stats the
|
|
940
|
+
// target, and a directory (or a dangling link) makes it throw, aborting
|
|
941
|
+
// the entire analyze. Workspace-linked checkouts made this reachable
|
|
942
|
+
// for every name, not just the pruned four — `dist -> build`, a
|
|
943
|
+
// vendored-grammar link, anything a sibling checkout ships.
|
|
944
|
+
//
|
|
945
|
+
// Such links are RECORDED by their link text rather than followed.
|
|
946
|
+
// Following them would (a) recurse without cycle protection — this
|
|
947
|
+
// traversal has none, so `self -> .` would ride the depth limit, which
|
|
948
|
+
// THROWS, trading one hard abort for another; (b) re-scan trees already
|
|
949
|
+
// reached by their real path, inflating the entry/byte budgets that
|
|
950
|
+
// also throw; and (c) need a whole containment/TOCTOU trust boundary
|
|
951
|
+
// for targets outside the package. Recording the text is cycle-free,
|
|
952
|
+
// costs one `readlink`, and still moves the receipt when the link is
|
|
953
|
+
// retargeted. The trade-off is that a link's target contributes no
|
|
954
|
+
// content of its own: when it points outside the package, only the
|
|
955
|
+
// link text is covered. Links that DO resolve to a regular file keep
|
|
956
|
+
// their content digest below, unchanged.
|
|
957
|
+
if (shouldHashRuntimePayload(relativePath)) {
|
|
958
|
+
budget.artifacts += 1;
|
|
959
|
+
if (budget.artifacts > limits.runtimePayloads) {
|
|
960
|
+
throw new Error(`Analyzer runtime payload scan exceeded ${limits.runtimePayloads} payloads: ${root}`);
|
|
961
|
+
}
|
|
962
|
+
artifacts.push({
|
|
963
|
+
absolutePath,
|
|
964
|
+
canonicalPath: `${canonicalPrefix}/${relativePath}`,
|
|
965
|
+
kind: 'unfollowed-symlink',
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
755
969
|
else if ((stat.isFile() || stat.isSymbolicLink()) &&
|
|
756
970
|
shouldHashRuntimePayload(relativePath)) {
|
|
757
971
|
const readableState = snapshotReadableFile(absolutePath);
|
|
@@ -889,7 +1103,7 @@ function dependencySnapshot(inputs) {
|
|
|
889
1103
|
absolutePath: artifact.absolutePath,
|
|
890
1104
|
canonicalPath: artifact.canonicalPath,
|
|
891
1105
|
kind: artifact.kind,
|
|
892
|
-
state:
|
|
1106
|
+
state: snapshotRuntimeArtifact(artifact),
|
|
893
1107
|
})),
|
|
894
1108
|
directories: [...inputs.directoryGuards.entries()]
|
|
895
1109
|
.map(([absolutePath, guard]) => ({ absolutePath, ...guard }))
|
|
@@ -903,9 +1117,26 @@ function artifactCacheKey(artifact) {
|
|
|
903
1117
|
return JSON.stringify([artifact.kind, artifact.canonicalPath, artifact.absolutePath]);
|
|
904
1118
|
}
|
|
905
1119
|
function hashRuntimeArtifact(artifact, cache, options) {
|
|
1120
|
+
if (artifact.kind === 'unfollowed-symlink') {
|
|
1121
|
+
// The link text is the entire payload, so there is no file read for a
|
|
1122
|
+
// cached digest to amortize: recompute it and stay independent of the
|
|
1123
|
+
// cache's freshness. The distinct frame label keeps a link recording from
|
|
1124
|
+
// ever colliding with a content digest.
|
|
1125
|
+
const state = snapshotSymlinkArtifact(artifact.absolutePath);
|
|
1126
|
+
return {
|
|
1127
|
+
...artifact,
|
|
1128
|
+
state,
|
|
1129
|
+
digest: hashCanonicalFrames([
|
|
1130
|
+
['runtime-payload-link-v1', artifact.kind, state.symlinkTarget],
|
|
1131
|
+
]),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
906
1134
|
const before = snapshotReadableFile(artifact.absolutePath);
|
|
907
|
-
if (cache &&
|
|
908
|
-
|
|
1135
|
+
if (cache &&
|
|
1136
|
+
cache.kind !== 'unfollowed-symlink' &&
|
|
1137
|
+
SHA256_PATTERN.test(cache.digest) &&
|
|
1138
|
+
isDeepStrictEqual(cache.state, before)) {
|
|
1139
|
+
return { ...artifact, state: before, digest: cache.digest };
|
|
909
1140
|
}
|
|
910
1141
|
const stable = hashStableFile(artifact.absolutePath);
|
|
911
1142
|
const digest = hashCanonicalFrames([
|
|
@@ -921,7 +1152,7 @@ function hashRuntimeArtifact(artifact, cache, options) {
|
|
|
921
1152
|
path: artifact.absolutePath,
|
|
922
1153
|
bytes: stable.bytes,
|
|
923
1154
|
});
|
|
924
|
-
return {
|
|
1155
|
+
return { ...artifact, state: stable.state, digest };
|
|
925
1156
|
}
|
|
926
1157
|
function compareEdges(a, b) {
|
|
927
1158
|
return compareBytes(JSON.stringify([
|
|
@@ -986,7 +1217,7 @@ function hashDependencyRuntime(inputs, cache, options, runtimeVariant) {
|
|
|
986
1217
|
artifact.kind,
|
|
987
1218
|
digestBytes(hashed.digest),
|
|
988
1219
|
]);
|
|
989
|
-
nextArtifacts.push(
|
|
1220
|
+
nextArtifacts.push(hashed);
|
|
990
1221
|
}
|
|
991
1222
|
return {
|
|
992
1223
|
identity: {
|
|
@@ -1014,6 +1245,17 @@ function isReadableFileState(value) {
|
|
|
1014
1245
|
isStatState(record.target) &&
|
|
1015
1246
|
(record.symlinkTarget === undefined || typeof record.symlinkTarget === 'string'));
|
|
1016
1247
|
}
|
|
1248
|
+
function isSymlinkArtifactState(value) {
|
|
1249
|
+
if (typeof value !== 'object' || value === null)
|
|
1250
|
+
return false;
|
|
1251
|
+
const record = value;
|
|
1252
|
+
// `target === undefined` keeps a readable-file state from masquerading as an
|
|
1253
|
+
// unresolved link recording, which would otherwise be validated against the
|
|
1254
|
+
// wrong guard mode on the warm path.
|
|
1255
|
+
return (isStatState(record.link) &&
|
|
1256
|
+
typeof record.symlinkTarget === 'string' &&
|
|
1257
|
+
record.target === undefined);
|
|
1258
|
+
}
|
|
1017
1259
|
function isDependencyPathGuardResult(value) {
|
|
1018
1260
|
if (value === null)
|
|
1019
1261
|
return true;
|
|
@@ -1159,13 +1401,16 @@ function isIdentityCachePayload(value, packageRoot, buildRoot, runtimeVariant, t
|
|
|
1159
1401
|
if (typeof entry !== 'object' || entry === null)
|
|
1160
1402
|
return false;
|
|
1161
1403
|
const item = entry;
|
|
1162
|
-
|
|
1163
|
-
path.isAbsolute(item.absolutePath)
|
|
1164
|
-
typeof item.canonicalPath
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1404
|
+
if (typeof item.absolutePath !== 'string' ||
|
|
1405
|
+
!path.isAbsolute(item.absolutePath) ||
|
|
1406
|
+
typeof item.canonicalPath !== 'string' ||
|
|
1407
|
+
typeof item.digest !== 'string' ||
|
|
1408
|
+
!SHA256_PATTERN.test(item.digest)) {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
return item.kind === 'unfollowed-symlink'
|
|
1412
|
+
? isSymlinkArtifactState(item.state)
|
|
1413
|
+
: (item.kind === 'file' || item.kind === 'symlink') && isReadableFileState(item.state);
|
|
1169
1414
|
});
|
|
1170
1415
|
const hasBuildRootGuard = record.buildDirectoryGuards.some((entry) => typeof entry === 'object' &&
|
|
1171
1416
|
entry !== null &&
|
|
@@ -1587,9 +1832,24 @@ function validateIdentityCache(cache, options) {
|
|
|
1587
1832
|
}
|
|
1588
1833
|
}
|
|
1589
1834
|
for (const artifact of cache.artifactEntries) {
|
|
1590
|
-
|
|
1835
|
+
// A recorded link is re-probed as a link, never as a readable file: the
|
|
1836
|
+
// readable-file probe resolves the target and would report `null` for the
|
|
1837
|
+
// very inputs this kind exists to describe, failing every warm validation.
|
|
1838
|
+
const probe = artifact.kind === 'unfollowed-symlink'
|
|
1839
|
+
? {
|
|
1840
|
+
request: { absolutePath: artifact.absolutePath, mode: 'link' },
|
|
1841
|
+
expected: {
|
|
1842
|
+
type: 'symlink',
|
|
1843
|
+
state: artifact.state.link,
|
|
1844
|
+
symlinkTarget: artifact.state.symlinkTarget,
|
|
1845
|
+
},
|
|
1846
|
+
}
|
|
1847
|
+
: {
|
|
1848
|
+
request: { absolutePath: artifact.absolutePath, mode: 'readable-file' },
|
|
1849
|
+
expected: { type: 'readable-file', state: artifact.state },
|
|
1850
|
+
};
|
|
1851
|
+
if (!add(probe.request, probe.expected))
|
|
1591
1852
|
return false;
|
|
1592
|
-
}
|
|
1593
1853
|
}
|
|
1594
1854
|
const entries = [...expected.entries()];
|
|
1595
1855
|
const requests = entries.map(([key]) => {
|
|
@@ -98,3 +98,111 @@ export declare const CREATE_VECTOR_INDEX_QUERY = "\nCALL CREATE_VECTOR_INDEX('Co
|
|
|
98
98
|
export declare const NODE_SCHEMA_QUERIES: string[];
|
|
99
99
|
export declare const REL_SCHEMA_QUERIES: string[];
|
|
100
100
|
export declare const SCHEMA_QUERIES: string[];
|
|
101
|
+
/**
|
|
102
|
+
* Digest of the graph DDL this build creates — the exact statements
|
|
103
|
+
* {@link runSchemaCreationQueries} (lbug-adapter.ts) executes for the node and
|
|
104
|
+
* relation tables.
|
|
105
|
+
*
|
|
106
|
+
* This REPLACED `INCREMENTAL_SCHEMA_VERSION` (#2798), a hand-incremented
|
|
107
|
+
* integer in repo-manager.ts that had to PREDICT whether an on-disk database
|
|
108
|
+
* was created from this build's DDL. It could not: the number collided with
|
|
109
|
+
* `main` eight times, twice EXACTLY, and an exact clash was the quiet failure —
|
|
110
|
+
* two builds stamp the same number over different DDL, the strict `===` gate
|
|
111
|
+
* reads the index as current, every `CREATE … TABLE` is skipped as "already
|
|
112
|
+
* exists" (suppressed in `runSchemaCreationQueries`), and the edges whose
|
|
113
|
+
* endpoint pair the live DB cannot persist are dropped by
|
|
114
|
+
* `fallbackRelationshipInserts`' bare `catch`. A wrong graph, not an error.
|
|
115
|
+
*
|
|
116
|
+
* A digest cannot collide BY ACCIDENT at this scale: 12 hex chars is 48 bits,
|
|
117
|
+
* so even 1,000 distinct DDL variants over the project's whole life put the
|
|
118
|
+
* birthday probability of any pair matching at ≈1.8e-9. Two builds agree
|
|
119
|
+
* exactly when their DDL agrees, so concurrent branches never need renumbering.
|
|
120
|
+
* Do not shorten the slice: the odds double per bit dropped. On mismatch —
|
|
121
|
+
* including the ABSENT stamp every pre-#2798 index carries — run-analyze warns
|
|
122
|
+
* and forces a full re-analyze, which wipes the database and recreates the
|
|
123
|
+
* tables from the DDL below.
|
|
124
|
+
*
|
|
125
|
+
* {@link EMBEDDING_SCHEMA} is deliberately EXCLUDED. Its `FLOAT[N]` width comes
|
|
126
|
+
* from `GITNEXUS_EMBEDDING_DIMS` at module load, so folding it in would make
|
|
127
|
+
* this a function of the ENVIRONMENT rather than of code: two runs of the same
|
|
128
|
+
* build under different env would disagree and force alternating full rebuilds.
|
|
129
|
+
* Vector-column drift is therefore a SEPARATE gate, not an ungated hazard:
|
|
130
|
+
* {@link embeddingDimsMismatch} compares the width stamped in
|
|
131
|
+
* `RepoMeta.embeddingDims` against {@link EMBEDDING_DIMS} and run-analyze
|
|
132
|
+
* forces a rebuild on drift. Do not merge the two — an env-derived value in a
|
|
133
|
+
* code digest makes the same build disagree with itself. (The older reaction in
|
|
134
|
+
* run-analyze remains, and is to the CACHE, not the schema: when the cached
|
|
135
|
+
* vectors' length differs from `EMBEDDING_DIMS` it discards the cache and
|
|
136
|
+
* re-embeds.)
|
|
137
|
+
*/
|
|
138
|
+
export declare const SCHEMA_FINGERPRINT: string;
|
|
139
|
+
/**
|
|
140
|
+
* Whether an index built under `recorded` can be reused by this build.
|
|
141
|
+
*
|
|
142
|
+
* Lives here rather than in run-analyze so the query side can ask the same
|
|
143
|
+
* question without importing the analyze pipeline — the reason
|
|
144
|
+
* `cjkSegmentationModeMismatch` sits in `core/search/` rather than beside its
|
|
145
|
+
* caller. ABSENT counts as a mismatch: that is the backward-compatibility path
|
|
146
|
+
* for every index written before the field existed, and grandfathering it would
|
|
147
|
+
* stamp a fresh fingerprint onto a database whose DDL was never verified.
|
|
148
|
+
*/
|
|
149
|
+
export declare const schemaFingerprintMismatch: (recorded: string | undefined) => boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Whether a stamped value has the shape {@link SCHEMA_FINGERPRINT} produces —
|
|
152
|
+
* the lowercase-hex prefix of a sha256 digest. The width is read from the live
|
|
153
|
+
* constant, so changing the slice above needs no edit here.
|
|
154
|
+
*
|
|
155
|
+
* Used to decide whether a stamp is worth NAMING in a diagnostic: an index with
|
|
156
|
+
* no fingerprint and one carrying a malformed value are both "not this build",
|
|
157
|
+
* but only the first has an explanation worth printing. Not a comparison gate —
|
|
158
|
+
* {@link schemaFingerprintMismatch} already rejects every value that is not
|
|
159
|
+
* exactly this build's.
|
|
160
|
+
*/
|
|
161
|
+
export declare const isSchemaFingerprintShaped: (value: unknown) => value is string;
|
|
162
|
+
/**
|
|
163
|
+
* Whether the vector-column width an index's `CodeEmbedding` table was created
|
|
164
|
+
* at (as persisted in `RepoMeta.embeddingDims`) differs from the width this
|
|
165
|
+
* process would embed at ({@link EMBEDDING_DIMS}). The gate
|
|
166
|
+
* {@link SCHEMA_FINGERPRINT} deliberately cannot be: `FLOAT[N]` comes from
|
|
167
|
+
* `GITNEXUS_EMBEDDING_DIMS` at module load, so folding it into a digest of the
|
|
168
|
+
* DDL would make that digest a function of the ENVIRONMENT. Splitting it out
|
|
169
|
+
* here keeps the fingerprint purely code-derived and still gates the width —
|
|
170
|
+
* before this, flipping `GITNEXUS_EMBEDDING_DIMS` on a same-commit clean tree
|
|
171
|
+
* fired no guard at all: `alreadyUpToDate` returned over a `FLOAT[384]` table
|
|
172
|
+
* while the process embedded at 768. (The one pre-existing reaction, in
|
|
173
|
+
* run-analyze, discards the embedding CACHE and re-embeds — into a column whose
|
|
174
|
+
* width was never revisited.) A single scalar, so plain equality suffices.
|
|
175
|
+
*
|
|
176
|
+
* ABSENT does NOT count as a mismatch — the opposite of
|
|
177
|
+
* {@link schemaFingerprintMismatch}, and deliberately:
|
|
178
|
+
*
|
|
179
|
+
* - Absence carries no signal about the width. A missing fingerprint means
|
|
180
|
+
* "DDL this build cannot vouch for", and the field ships WITH a DDL change,
|
|
181
|
+
* so absence is itself evidence of drift. A missing dims stamp means only
|
|
182
|
+
* "written before the field existed"; the width was whatever that run's env
|
|
183
|
+
* resolved, almost always the 384 default, and it was consistent with the
|
|
184
|
+
* table it wrote. Drift needs the env to CHANGE, which absence says nothing
|
|
185
|
+
* about.
|
|
186
|
+
* - Forcing on absence would buy no safety anyway. Every index that lacks this
|
|
187
|
+
* stamp also lacks `schemaFingerprint` (both landed together in #2798), and
|
|
188
|
+
* that guard already forces a rebuild for exactly those indexes — after
|
|
189
|
+
* which the width is stamped and the hazard is closed for good. A second
|
|
190
|
+
* trigger for the same one rebuild is dead weight that would keep firing
|
|
191
|
+
* forever on any future path that legitimately omits the stamp.
|
|
192
|
+
* - The cost of guessing wrong is asymmetric: a fleet-wide full re-analyze
|
|
193
|
+
* (minutes to hours per repo) for a hazard that requires a rare, deliberate
|
|
194
|
+
* env change.
|
|
195
|
+
*
|
|
196
|
+
* Absence is precisely `undefined`. Any other recorded value that is not this
|
|
197
|
+
* build's width — including a malformed one, since `meta.json` is a schema-less
|
|
198
|
+
* `JSON.parse` of on-disk state — reads as a mismatch and errs toward a
|
|
199
|
+
* rebuild, which is the safe direction.
|
|
200
|
+
*
|
|
201
|
+
* Pure + exported for testing, and takes `current` explicitly rather than
|
|
202
|
+
* closing over {@link EMBEDDING_DIMS}: that constant is frozen at module load,
|
|
203
|
+
* so a parameter is the only way to exercise both sides of the comparison.
|
|
204
|
+
* Lives here rather than in run-analyze for the reason
|
|
205
|
+
* `cjkSegmentationModeMismatch` lives in `core/search/` — a caller that only
|
|
206
|
+
* needs the comparator should not have to pull in the analyze pipeline.
|
|
207
|
+
*/
|
|
208
|
+
export declare const embeddingDimsMismatch: (recorded: number | undefined, current: number) => boolean;
|
package/dist/core/lbug/schema.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* This allows LLMs to write natural Cypher queries like:
|
|
9
9
|
* MATCH (f:Function)-[r:CodeRelation {type: 'CALLS'}]->(g:Function) RETURN f, g
|
|
10
10
|
*/
|
|
11
|
+
import { createHash } from 'crypto';
|
|
11
12
|
// Import from shared package (single source of truth) — used in DDL templates below
|
|
12
13
|
import { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME } from '../../_shared/index.js';
|
|
13
14
|
import { parseRelationSchemaPairs } from './rel-pair-routing.js';
|
|
@@ -614,3 +615,116 @@ export const NODE_SCHEMA_QUERIES = [
|
|
|
614
615
|
];
|
|
615
616
|
export const REL_SCHEMA_QUERIES = [RELATION_SCHEMA];
|
|
616
617
|
export const SCHEMA_QUERIES = [...NODE_SCHEMA_QUERIES, ...REL_SCHEMA_QUERIES, EMBEDDING_SCHEMA];
|
|
618
|
+
/**
|
|
619
|
+
* Digest of the graph DDL this build creates — the exact statements
|
|
620
|
+
* {@link runSchemaCreationQueries} (lbug-adapter.ts) executes for the node and
|
|
621
|
+
* relation tables.
|
|
622
|
+
*
|
|
623
|
+
* This REPLACED `INCREMENTAL_SCHEMA_VERSION` (#2798), a hand-incremented
|
|
624
|
+
* integer in repo-manager.ts that had to PREDICT whether an on-disk database
|
|
625
|
+
* was created from this build's DDL. It could not: the number collided with
|
|
626
|
+
* `main` eight times, twice EXACTLY, and an exact clash was the quiet failure —
|
|
627
|
+
* two builds stamp the same number over different DDL, the strict `===` gate
|
|
628
|
+
* reads the index as current, every `CREATE … TABLE` is skipped as "already
|
|
629
|
+
* exists" (suppressed in `runSchemaCreationQueries`), and the edges whose
|
|
630
|
+
* endpoint pair the live DB cannot persist are dropped by
|
|
631
|
+
* `fallbackRelationshipInserts`' bare `catch`. A wrong graph, not an error.
|
|
632
|
+
*
|
|
633
|
+
* A digest cannot collide BY ACCIDENT at this scale: 12 hex chars is 48 bits,
|
|
634
|
+
* so even 1,000 distinct DDL variants over the project's whole life put the
|
|
635
|
+
* birthday probability of any pair matching at ≈1.8e-9. Two builds agree
|
|
636
|
+
* exactly when their DDL agrees, so concurrent branches never need renumbering.
|
|
637
|
+
* Do not shorten the slice: the odds double per bit dropped. On mismatch —
|
|
638
|
+
* including the ABSENT stamp every pre-#2798 index carries — run-analyze warns
|
|
639
|
+
* and forces a full re-analyze, which wipes the database and recreates the
|
|
640
|
+
* tables from the DDL below.
|
|
641
|
+
*
|
|
642
|
+
* {@link EMBEDDING_SCHEMA} is deliberately EXCLUDED. Its `FLOAT[N]` width comes
|
|
643
|
+
* from `GITNEXUS_EMBEDDING_DIMS` at module load, so folding it in would make
|
|
644
|
+
* this a function of the ENVIRONMENT rather than of code: two runs of the same
|
|
645
|
+
* build under different env would disagree and force alternating full rebuilds.
|
|
646
|
+
* Vector-column drift is therefore a SEPARATE gate, not an ungated hazard:
|
|
647
|
+
* {@link embeddingDimsMismatch} compares the width stamped in
|
|
648
|
+
* `RepoMeta.embeddingDims` against {@link EMBEDDING_DIMS} and run-analyze
|
|
649
|
+
* forces a rebuild on drift. Do not merge the two — an env-derived value in a
|
|
650
|
+
* code digest makes the same build disagree with itself. (The older reaction in
|
|
651
|
+
* run-analyze remains, and is to the CACHE, not the schema: when the cached
|
|
652
|
+
* vectors' length differs from `EMBEDDING_DIMS` it discards the cache and
|
|
653
|
+
* re-embeds.)
|
|
654
|
+
*/
|
|
655
|
+
export const SCHEMA_FINGERPRINT = createHash('sha256')
|
|
656
|
+
.update([...NODE_SCHEMA_QUERIES, ...REL_SCHEMA_QUERIES].join('\n'))
|
|
657
|
+
.digest('hex')
|
|
658
|
+
.slice(0, 12);
|
|
659
|
+
/**
|
|
660
|
+
* Whether an index built under `recorded` can be reused by this build.
|
|
661
|
+
*
|
|
662
|
+
* Lives here rather than in run-analyze so the query side can ask the same
|
|
663
|
+
* question without importing the analyze pipeline — the reason
|
|
664
|
+
* `cjkSegmentationModeMismatch` sits in `core/search/` rather than beside its
|
|
665
|
+
* caller. ABSENT counts as a mismatch: that is the backward-compatibility path
|
|
666
|
+
* for every index written before the field existed, and grandfathering it would
|
|
667
|
+
* stamp a fresh fingerprint onto a database whose DDL was never verified.
|
|
668
|
+
*/
|
|
669
|
+
export const schemaFingerprintMismatch = (recorded) => recorded !== SCHEMA_FINGERPRINT;
|
|
670
|
+
/**
|
|
671
|
+
* Whether a stamped value has the shape {@link SCHEMA_FINGERPRINT} produces —
|
|
672
|
+
* the lowercase-hex prefix of a sha256 digest. The width is read from the live
|
|
673
|
+
* constant, so changing the slice above needs no edit here.
|
|
674
|
+
*
|
|
675
|
+
* Used to decide whether a stamp is worth NAMING in a diagnostic: an index with
|
|
676
|
+
* no fingerprint and one carrying a malformed value are both "not this build",
|
|
677
|
+
* but only the first has an explanation worth printing. Not a comparison gate —
|
|
678
|
+
* {@link schemaFingerprintMismatch} already rejects every value that is not
|
|
679
|
+
* exactly this build's.
|
|
680
|
+
*/
|
|
681
|
+
export const isSchemaFingerprintShaped = (value) => typeof value === 'string' &&
|
|
682
|
+
value.length === SCHEMA_FINGERPRINT.length &&
|
|
683
|
+
/^[0-9a-f]+$/.test(value);
|
|
684
|
+
/**
|
|
685
|
+
* Whether the vector-column width an index's `CodeEmbedding` table was created
|
|
686
|
+
* at (as persisted in `RepoMeta.embeddingDims`) differs from the width this
|
|
687
|
+
* process would embed at ({@link EMBEDDING_DIMS}). The gate
|
|
688
|
+
* {@link SCHEMA_FINGERPRINT} deliberately cannot be: `FLOAT[N]` comes from
|
|
689
|
+
* `GITNEXUS_EMBEDDING_DIMS` at module load, so folding it into a digest of the
|
|
690
|
+
* DDL would make that digest a function of the ENVIRONMENT. Splitting it out
|
|
691
|
+
* here keeps the fingerprint purely code-derived and still gates the width —
|
|
692
|
+
* before this, flipping `GITNEXUS_EMBEDDING_DIMS` on a same-commit clean tree
|
|
693
|
+
* fired no guard at all: `alreadyUpToDate` returned over a `FLOAT[384]` table
|
|
694
|
+
* while the process embedded at 768. (The one pre-existing reaction, in
|
|
695
|
+
* run-analyze, discards the embedding CACHE and re-embeds — into a column whose
|
|
696
|
+
* width was never revisited.) A single scalar, so plain equality suffices.
|
|
697
|
+
*
|
|
698
|
+
* ABSENT does NOT count as a mismatch — the opposite of
|
|
699
|
+
* {@link schemaFingerprintMismatch}, and deliberately:
|
|
700
|
+
*
|
|
701
|
+
* - Absence carries no signal about the width. A missing fingerprint means
|
|
702
|
+
* "DDL this build cannot vouch for", and the field ships WITH a DDL change,
|
|
703
|
+
* so absence is itself evidence of drift. A missing dims stamp means only
|
|
704
|
+
* "written before the field existed"; the width was whatever that run's env
|
|
705
|
+
* resolved, almost always the 384 default, and it was consistent with the
|
|
706
|
+
* table it wrote. Drift needs the env to CHANGE, which absence says nothing
|
|
707
|
+
* about.
|
|
708
|
+
* - Forcing on absence would buy no safety anyway. Every index that lacks this
|
|
709
|
+
* stamp also lacks `schemaFingerprint` (both landed together in #2798), and
|
|
710
|
+
* that guard already forces a rebuild for exactly those indexes — after
|
|
711
|
+
* which the width is stamped and the hazard is closed for good. A second
|
|
712
|
+
* trigger for the same one rebuild is dead weight that would keep firing
|
|
713
|
+
* forever on any future path that legitimately omits the stamp.
|
|
714
|
+
* - The cost of guessing wrong is asymmetric: a fleet-wide full re-analyze
|
|
715
|
+
* (minutes to hours per repo) for a hazard that requires a rare, deliberate
|
|
716
|
+
* env change.
|
|
717
|
+
*
|
|
718
|
+
* Absence is precisely `undefined`. Any other recorded value that is not this
|
|
719
|
+
* build's width — including a malformed one, since `meta.json` is a schema-less
|
|
720
|
+
* `JSON.parse` of on-disk state — reads as a mismatch and errs toward a
|
|
721
|
+
* rebuild, which is the safe direction.
|
|
722
|
+
*
|
|
723
|
+
* Pure + exported for testing, and takes `current` explicitly rather than
|
|
724
|
+
* closing over {@link EMBEDDING_DIMS}: that constant is frozen at module load,
|
|
725
|
+
* so a parameter is the only way to exercise both sides of the comparison.
|
|
726
|
+
* Lives here rather than in run-analyze for the reason
|
|
727
|
+
* `cjkSegmentationModeMismatch` lives in `core/search/` — a caller that only
|
|
728
|
+
* needs the comparator should not have to pull in the analyze pipeline.
|
|
729
|
+
*/
|
|
730
|
+
export const embeddingDimsMismatch = (recorded, current) => recorded !== undefined && recorded !== current;
|