knodin 0.13.0 → 0.14.0
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/bin/cli.js +120 -9
- package/dist/src/backup-retention.js +345 -12
- package/dist/src/class-consumer-cursor.js +2 -0
- package/dist/src/cli-model.js +12 -0
- package/dist/src/compact-structural.js +7 -2
- package/dist/src/engine/apex-class-uses.js +54 -8
- package/dist/src/engine/apex-dead-chains.js +368 -0
- package/dist/src/engine/apex-entry-points.js +44 -8
- package/dist/src/engine/candidate-database.js +624 -66
- package/dist/src/engine/index.js +1768 -258
- package/dist/src/engine/seal.js +28 -4
- package/dist/src/engine/state-paths.js +192 -31
- package/dist/src/init.js +14 -2
- package/dist/src/manager-update.js +9 -10
- package/dist/src/mirror.js +2 -0
- package/dist/src/response-budget.js +14 -2
- package/dist/src/shared-index/restore.js +16 -6
- package/dist/src/storage-budget.js +146 -0
- package/dist/src/storage-inventory.js +263 -0
- package/dist/src/storage-management.js +204 -0
- package/dist/src/storage-policy-contract.js +121 -0
- package/dist/src/tools/knodin-tools.js +1 -0
- package/dist/src/worktree-seed.js +35 -2
- package/docs/BACKUP-RETENTION.md +46 -2
- package/docs/CLI.md +12 -5
- package/docs/DEAD-CODE-AND-IMPACT.md +41 -0
- package/docs/MCP.md +15 -3
- package/docs/SALESFORCE-BINDINGS.md +26 -8
- package/docs/releases/0.13.1.md +216 -0
- package/docs/releases/0.14.0.md +78 -0
- package/package.json +4 -1
package/dist/src/engine/index.js
CHANGED
|
@@ -15,8 +15,10 @@ import child_process from "node:child_process";
|
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import fs from "node:fs";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
+
import os from "node:os";
|
|
18
19
|
import path from "node:path";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { isDeepStrictEqual } from "node:util";
|
|
20
22
|
import chokidar from "chokidar";
|
|
21
23
|
import Parser from "web-tree-sitter";
|
|
22
24
|
import { ClassConsumerQueryError, } from "../class-consumer-contract.js";
|
|
@@ -30,14 +32,18 @@ import { runReadOnlyLspQuery } from "../lsp-readonly.js";
|
|
|
30
32
|
import { evidenceHandleSecret } from "../progressive-evidence.js";
|
|
31
33
|
import { acquireRepairLease } from "../repair-lease.js";
|
|
32
34
|
import { analyzeResourceReachability, resourceFingerprint, } from "../resource-reachability.js";
|
|
35
|
+
import { assertStorageCapacity, measureDatabaseFamily } from "../storage-budget.js";
|
|
36
|
+
import { assertManagedStorageAllocation, initializeStoragePolicy, postOperationStorageMaintenance, } from "../storage-management.js";
|
|
37
|
+
import { assertStoragePolicyWritable } from "../storage-policy-contract.js";
|
|
33
38
|
import { contentFingerprint, writeStructuralSnapshot, } from "../structural-snapshot.js";
|
|
34
39
|
import { acquireLifecycleCoordination } from "../update-coordination.js";
|
|
35
40
|
import { KNODIN_VERSION } from "../version.js";
|
|
36
41
|
import * as ann from "./ann-hnsw.js";
|
|
37
|
-
import { createApexConsumerCatalog, extractApexClassUses, resolveApexClassUses, } from "./apex-class-uses.js";
|
|
38
|
-
import {
|
|
42
|
+
import { createApexConsumerCatalog, extractApexClassUses, packApexConsumerCatalog, resolveApexClassUses, unpackApexConsumerCatalog, } from "./apex-class-uses.js";
|
|
43
|
+
import { apexDeadChains } from "./apex-dead-chains.js";
|
|
44
|
+
import { apexOrgEntryPoints, apexPlatformEntryPoints, } from "./apex-entry-points.js";
|
|
39
45
|
import { apexCall, apexDeclaration, apexReceiverCapability, apexReferenceReadGuard, parseApexDeclaration, persistApexReferenceEndpoints, } from "./apex-receiver.js";
|
|
40
|
-
import { allocateCandidate, assertCandidate, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, sweepAbandonedCandidates, } from "./candidate-database.js";
|
|
46
|
+
import { allocateCandidate, assertCandidate, assertCandidateResumeOwner, bindCandidateDatabase, discardCandidateFiles, listCandidates, promoteCandidateFile, recoverInterruptedPromotion, sweepAbandonedCandidates, } from "./candidate-database.js";
|
|
41
47
|
import { EmbeddingReuseCache, embeddingReuseKey } from "./embedding-reuse.js";
|
|
42
48
|
import { computeSimilarity, generateEmbedding, generateEmbeddings, getEmbeddingCacheIdentity, } from "./embeddings.js";
|
|
43
49
|
import { walkRepoFiles } from "./file-walker.js";
|
|
@@ -49,7 +55,7 @@ import { canonicalCoveragePath, coverageContains, coverageGitPathspecBatches, co
|
|
|
49
55
|
// resolves inside the worker thread, never in this one.
|
|
50
56
|
import { createParsePool, parseWorkersEnabled } from "./parse-pool.js";
|
|
51
57
|
import { beginPerfPhase, measurePerfPhase, measurePerfPhaseSync } from "./perf.js";
|
|
52
|
-
import { isIndexablePath, makeWatchIgnorePredicate } from "./prune.js";
|
|
58
|
+
import { isIndexablePath, makeWatchIgnorePredicate, prunePolicyDescriptor } from "./prune.js";
|
|
53
59
|
import { reflinkCopyFile } from "./reflink-copy.js";
|
|
54
60
|
import { componentBindings, componentCatalog, visualforceInlineScripts, } from "./salesforce-components.js";
|
|
55
61
|
import { readSarifLog, SARIF_DEFAULT_LIMITS, } from "./sarif-import.js";
|
|
@@ -57,7 +63,7 @@ import { readScipIndex, SCIP_DEFAULT_LIMITS, } from "./scip-import.js";
|
|
|
57
63
|
import { isSealedDatabase, listSealedFiles, readSealedSource } from "./seal.js";
|
|
58
64
|
import { isIndexableSourcePath } from "./source-policy.js";
|
|
59
65
|
import { Database } from "./sqlite.js";
|
|
60
|
-
import { lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
|
|
66
|
+
import { isMirror, lookupMirror, mayWriteToRepository, resolveDbPath, resolveStateDir, } from "./state-paths.js";
|
|
61
67
|
import { deleteAllSymbols, deleteSymbolsForFile, deleteSymbolsMatchingPath, ORPHANED_EMBEDDING_PREDICATE, purgeOrphanEmbeddings, } from "./symbol-delete.js";
|
|
62
68
|
import { searchRepoText } from "./text-matches.js";
|
|
63
69
|
// ES Module resolution
|
|
@@ -708,7 +714,7 @@ function persistReferenceEndpoints(db, filePaths, deferApex = false) {
|
|
|
708
714
|
persistApexClassConsumerEndpoints(db);
|
|
709
715
|
}
|
|
710
716
|
}
|
|
711
|
-
const APEX_CONSUMER_BINDING_VERSION = "
|
|
717
|
+
const APEX_CONSUMER_BINDING_VERSION = "4-instanceof-types";
|
|
712
718
|
/** Classes retain receiver provenance; interfaces use their independent parser span proof. */
|
|
713
719
|
function apexConsumerSymbolOwner(db, symbol, cache) {
|
|
714
720
|
if (symbol.kind === "class" &&
|
|
@@ -743,10 +749,12 @@ function apexConsumerSymbolOwner(db, symbol, cache) {
|
|
|
743
749
|
return matches.length === 1 ? matches[0].ownerPath : null;
|
|
744
750
|
}
|
|
745
751
|
const apexConsumerKinds = new Set([
|
|
752
|
+
"static_method_receiver",
|
|
746
753
|
"static_field_read",
|
|
747
754
|
"constructor",
|
|
748
755
|
"variable_type",
|
|
749
756
|
"cast",
|
|
757
|
+
"instanceof_type",
|
|
750
758
|
"class_literal",
|
|
751
759
|
"parameter_type",
|
|
752
760
|
"generic_parameter_type",
|
|
@@ -770,6 +778,13 @@ function validConsumerSite(value) {
|
|
|
770
778
|
typeof site.typePath === "string" &&
|
|
771
779
|
/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*$/.test(site.typePath) &&
|
|
772
780
|
(site.memberName === null || typeof site.memberName === "string") &&
|
|
781
|
+
(site.kind !== "instanceof_type" || site.memberName === null) &&
|
|
782
|
+
(site.kind === "static_method_receiver"
|
|
783
|
+
? typeof site.memberName === "string" &&
|
|
784
|
+
/^[A-Za-z_]\w*$/.test(site.memberName) &&
|
|
785
|
+
Number.isSafeInteger(site.arity) &&
|
|
786
|
+
site.arity >= 0
|
|
787
|
+
: site.arity === undefined) &&
|
|
773
788
|
(site.lexicalOwner === null || typeof site.lexicalOwner === "string") &&
|
|
774
789
|
[null, "value-shadow", "malformed-source"].includes(site.blockedReason) &&
|
|
775
790
|
validConsumerPosition(site.start) &&
|
|
@@ -784,6 +799,10 @@ function readApexConsumerFacts(row) {
|
|
|
784
799
|
return null;
|
|
785
800
|
try {
|
|
786
801
|
const facts = JSON.parse(row.facts);
|
|
802
|
+
if (facts.methodReceiverVersion !== undefined && facts.methodReceiverVersion !== 1)
|
|
803
|
+
return null;
|
|
804
|
+
if (facts.instanceofTypeVersion !== undefined && facts.instanceofTypeVersion !== 1)
|
|
805
|
+
return null;
|
|
787
806
|
if (facts.heritageComplete !== undefined && typeof facts.heritageComplete !== "boolean")
|
|
788
807
|
return null;
|
|
789
808
|
if (facts.heritage !== undefined &&
|
|
@@ -822,7 +841,12 @@ function readApexConsumerFacts(row) {
|
|
|
822
841
|
!Array.isArray(facts.unsupportedUses))
|
|
823
842
|
return null;
|
|
824
843
|
if (!facts.declarations.every((decl) => decl.version === 1 &&
|
|
825
|
-
["class", "interface", "enum", "field", "property"].includes(decl.kind) &&
|
|
844
|
+
["class", "interface", "enum", "field", "property", "method"].includes(decl.kind) &&
|
|
845
|
+
(decl.kind === "method"
|
|
846
|
+
? facts.methodReceiverVersion === 1 &&
|
|
847
|
+
Number.isSafeInteger(decl.arity) &&
|
|
848
|
+
decl.arity >= 0
|
|
849
|
+
: decl.arity === undefined) &&
|
|
826
850
|
typeof decl.ownerPath === "string" &&
|
|
827
851
|
typeof decl.name === "string" &&
|
|
828
852
|
typeof decl.static === "boolean" &&
|
|
@@ -837,6 +861,12 @@ function readApexConsumerFacts(row) {
|
|
|
837
861
|
decl.declarationRange.start.byte <= decl.start.byte &&
|
|
838
862
|
decl.declarationRange.end.byte >= decl.end.byte))))
|
|
839
863
|
return null;
|
|
864
|
+
if (facts.methodReceiverVersion !== 1 &&
|
|
865
|
+
facts.sites.some((site) => site.kind === "static_method_receiver"))
|
|
866
|
+
return null;
|
|
867
|
+
if (facts.instanceofTypeVersion !== 1 &&
|
|
868
|
+
facts.sites.some((site) => site.kind === "instanceof_type"))
|
|
869
|
+
return null;
|
|
840
870
|
return facts;
|
|
841
871
|
}
|
|
842
872
|
catch {
|
|
@@ -847,9 +877,11 @@ function readApexConsumerFacts(row) {
|
|
|
847
877
|
function apexConsumerMaterializationDigest(db) {
|
|
848
878
|
const hash = crypto.createHash("sha256");
|
|
849
879
|
let after;
|
|
880
|
+
// Keep the indexed tuple bare so SQLite seeks each page instead of rescanning the prefix.
|
|
881
|
+
// RHS and ORDER BY collations still explicitly preserve the byte-ordered digest contract.
|
|
850
882
|
for (;;) {
|
|
851
883
|
const rows = db
|
|
852
|
-
.query(`SELECT filePath,startByte,endByte,kind,raw,status,targetFile,targetOwner,targetIdentity,ownerIdentity,isTest FROM apex_class_uses ${after ? "WHERE (filePath
|
|
884
|
+
.query(`SELECT filePath,startByte,endByte,kind,raw,status,targetFile,targetOwner,targetIdentity,ownerIdentity,isTest FROM apex_class_uses ${after ? "WHERE (filePath,startByte,endByte,kind)>(? COLLATE BINARY,?,?,? COLLATE BINARY)" : ""} ORDER BY filePath COLLATE BINARY,startByte,endByte,kind COLLATE BINARY LIMIT 500`)
|
|
853
885
|
.all(...(after ? [after.filePath, after.startByte, after.endByte, after.kind] : []));
|
|
854
886
|
for (const row of rows)
|
|
855
887
|
hash.update(JSON.stringify(row)).update("\n");
|
|
@@ -957,6 +989,10 @@ function missingApexConsumerDescriptorFiles(db) {
|
|
|
957
989
|
return false;
|
|
958
990
|
if (facts.parseState !== "complete")
|
|
959
991
|
return true;
|
|
992
|
+
if (facts.methodReceiverVersion !== 1)
|
|
993
|
+
return false;
|
|
994
|
+
if (facts.instanceofTypeVersion !== 1)
|
|
995
|
+
return false;
|
|
960
996
|
if (facts.heritage === undefined &&
|
|
961
997
|
facts.unsupportedUses.some((use) => use.kind === "inheritance"))
|
|
962
998
|
return false;
|
|
@@ -978,6 +1014,19 @@ const apexConsumerFastHits = new WeakMap();
|
|
|
978
1014
|
const apexConsumerValidationMemoStats = new WeakMap();
|
|
979
1015
|
const APEX_CONSUMER_MEMO_MAX_ENTRIES = 4;
|
|
980
1016
|
const APEX_CONSUMER_MEMO_MAX_BYTES = 8 * 1024 * 1024;
|
|
1017
|
+
function hydrateApexConsumerMemo(db) {
|
|
1018
|
+
const cached = apexConsumerValidationMemo.get(db);
|
|
1019
|
+
if (!cached)
|
|
1020
|
+
throw new Error("Apex consumer validation memo disappeared");
|
|
1021
|
+
try {
|
|
1022
|
+
return { ...cached.summary, catalog: unpackApexConsumerCatalog(cached.packedCatalog) };
|
|
1023
|
+
}
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
// A fast-proof read intentionally skipped stored facts. Never rebuild from its empty list.
|
|
1026
|
+
apexConsumerValidationMemo.delete(db);
|
|
1027
|
+
throw error;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
981
1030
|
/** Diagnostic counters only; never expose retained source or descriptors. */
|
|
982
1031
|
export function getApexConsumerValidationMemoStats(db) {
|
|
983
1032
|
return {
|
|
@@ -1054,7 +1103,7 @@ function validatedApexConsumerSummary(db, stored, key) {
|
|
|
1054
1103
|
stats.hits++;
|
|
1055
1104
|
apexConsumerValidationMemo.delete(db);
|
|
1056
1105
|
apexConsumerValidationMemo.set(db, cached);
|
|
1057
|
-
return
|
|
1106
|
+
return hydrateApexConsumerMemo(db);
|
|
1058
1107
|
}
|
|
1059
1108
|
stats.misses++;
|
|
1060
1109
|
apexConsumerValidationMemo.delete(db);
|
|
@@ -1063,15 +1112,19 @@ function validatedApexConsumerSummary(db, stored, key) {
|
|
|
1063
1112
|
.filter((facts) => facts !== null);
|
|
1064
1113
|
const summary = {
|
|
1065
1114
|
validFiles: valid.length,
|
|
1115
|
+
legacyReceiverFiles: valid.filter((facts) => facts.methodReceiverVersion !== 1).length,
|
|
1116
|
+
legacyInstanceofFiles: valid.filter((facts) => facts.instanceofTypeVersion !== 1).length,
|
|
1066
1117
|
invalidDescriptorFiles: stored.length - valid.length,
|
|
1067
1118
|
partialFiles: valid.filter((facts) => facts.parseState !== "complete").length,
|
|
1068
1119
|
expectedSites: valid.reduce((count, facts) => count + facts.sites.length, 0),
|
|
1069
1120
|
unsupportedUses: valid.reduce((count, facts) => count + facts.unsupportedUses.length, 0),
|
|
1070
1121
|
catalog: createApexConsumerCatalog(valid),
|
|
1071
1122
|
};
|
|
1072
|
-
//
|
|
1073
|
-
//
|
|
1074
|
-
const
|
|
1123
|
+
// Retain only the lossless buffer and scalar metadata, never the hydrated catalog or raw facts.
|
|
1124
|
+
// The cap bounds retained payload, not transient decode/validation heap. Reserve proof overhead.
|
|
1125
|
+
const { catalog, ...counts } = summary;
|
|
1126
|
+
const packedCatalog = packApexConsumerCatalog(catalog);
|
|
1127
|
+
const retainedBytes = packedCatalog.byteLength + Buffer.byteLength(JSON.stringify({ key, summary: counts })) + 1024;
|
|
1075
1128
|
if (summary.invalidDescriptorFiles === 0 && retainedBytes <= APEX_CONSUMER_MEMO_MAX_BYTES) {
|
|
1076
1129
|
while (apexConsumerValidationMemo.size >= APEX_CONSUMER_MEMO_MAX_ENTRIES) {
|
|
1077
1130
|
const oldest = apexConsumerValidationMemo.keys().next().value;
|
|
@@ -1079,7 +1132,7 @@ function validatedApexConsumerSummary(db, stored, key) {
|
|
|
1079
1132
|
break;
|
|
1080
1133
|
apexConsumerValidationMemo.delete(oldest);
|
|
1081
1134
|
}
|
|
1082
|
-
apexConsumerValidationMemo.set(db, { key, summary, retainedBytes });
|
|
1135
|
+
apexConsumerValidationMemo.set(db, { key, summary: counts, packedCatalog, retainedBytes });
|
|
1083
1136
|
}
|
|
1084
1137
|
return summary;
|
|
1085
1138
|
}
|
|
@@ -1281,7 +1334,9 @@ export function readClassConsumerPage(db, repoPath, request) {
|
|
|
1281
1334
|
bindingVersion,
|
|
1282
1335
|
expectedMaterialization: getMeta(db, "apexConsumerMaterializationDigest"),
|
|
1283
1336
|
});
|
|
1284
|
-
const validation = fastProof && memo
|
|
1337
|
+
const validation = fastProof && memo
|
|
1338
|
+
? hydrateApexConsumerMemo(db)
|
|
1339
|
+
: validatedApexConsumerSummary(db, stored, validationDigest);
|
|
1285
1340
|
const { expectedSites, invalidDescriptorFiles, partialFiles, unsupportedUses, catalog } = validation;
|
|
1286
1341
|
// Fresh symbol proof is intentionally outside both validation caches. A removed outer
|
|
1287
1342
|
// interface must not turn retained nested-class rows into a certified zero result.
|
|
@@ -1509,6 +1564,8 @@ export function readClassConsumerPage(db, repoPath, request) {
|
|
|
1509
1564
|
partial: !bound ||
|
|
1510
1565
|
missingDescriptorFiles > 0 ||
|
|
1511
1566
|
partialFiles > 0 ||
|
|
1567
|
+
validation.legacyReceiverFiles > 0 ||
|
|
1568
|
+
validation.legacyInstanceofFiles > 0 ||
|
|
1512
1569
|
invalidRows ||
|
|
1513
1570
|
unresolvedSites > 0 ||
|
|
1514
1571
|
unsupportedUses > 0,
|
|
@@ -1519,7 +1576,17 @@ export function readClassConsumerPage(db, repoPath, request) {
|
|
|
1519
1576
|
unsupportedUses,
|
|
1520
1577
|
exclusions: { targetInternalSites },
|
|
1521
1578
|
limitations: [
|
|
1522
|
-
"Only
|
|
1579
|
+
"Only supported explicit Apex syntax roles are resolved; this is not runtime reachability or complete class-use coverage.",
|
|
1580
|
+
...(validation.legacyInstanceofFiles > 0
|
|
1581
|
+
? [
|
|
1582
|
+
"Legacy descriptors lack instanceof type capability; run knodin repair to re-extract affected Apex files before treating zero type checks as complete.",
|
|
1583
|
+
]
|
|
1584
|
+
: []),
|
|
1585
|
+
...(validation.legacyReceiverFiles > 0
|
|
1586
|
+
? [
|
|
1587
|
+
"Legacy descriptors lack static-method receiver capability; run knodin repair to re-extract affected Apex files before treating zero receivers as complete.",
|
|
1588
|
+
]
|
|
1589
|
+
: []),
|
|
1523
1590
|
"Descriptor, unresolved-site and unsupported-use counters cover the inspected indexed Apex inventory, not just this target.",
|
|
1524
1591
|
"Inherited or ambiguous value/type scopes can remain unresolved; target self-file sites are excluded.",
|
|
1525
1592
|
"Query results cover this repository's indexed graph scope only, not federated repositories or metadata.",
|
|
@@ -1533,6 +1600,248 @@ export function readClassConsumerPage(db, repoPath, request) {
|
|
|
1533
1600
|
releaseApexConsumerRead(db, beforeStamp !== null, completed, publishProof);
|
|
1534
1601
|
}
|
|
1535
1602
|
}
|
|
1603
|
+
/** One bounded source read/parse; selectors retain full indexed-row correspondence. */
|
|
1604
|
+
async function verifyPlatformEntryFile(db, repoPath, relativePath, file) {
|
|
1605
|
+
const base = {
|
|
1606
|
+
version: 1,
|
|
1607
|
+
platform: "salesforce",
|
|
1608
|
+
runtimeInvocation: "not-established",
|
|
1609
|
+
};
|
|
1610
|
+
const unavailable = (reason) => () => ({
|
|
1611
|
+
...base,
|
|
1612
|
+
status: "unavailable",
|
|
1613
|
+
reason,
|
|
1614
|
+
});
|
|
1615
|
+
const maxBytes = 1024 * 1024;
|
|
1616
|
+
let sourceText;
|
|
1617
|
+
try {
|
|
1618
|
+
const sealed = sealedRepositories.get(path.resolve(repoPath));
|
|
1619
|
+
if (sealed) {
|
|
1620
|
+
const text = readSealedSource(sealed, relativePath, maxBytes);
|
|
1621
|
+
if (text === null)
|
|
1622
|
+
return unavailable("Apex sealed source is missing or exceeds the bounded verification limit.");
|
|
1623
|
+
sourceText = text;
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
const fd = fs.openSync(path.resolve(repoPath, relativePath), "r");
|
|
1627
|
+
try {
|
|
1628
|
+
if (!fs.fstatSync(fd).isFile() || fs.fstatSync(fd).size > maxBytes)
|
|
1629
|
+
return unavailable("Apex source exceeds the bounded platform entry verification limit.");
|
|
1630
|
+
const bytes = Buffer.alloc(maxBytes + 1);
|
|
1631
|
+
let length = 0;
|
|
1632
|
+
while (length < bytes.length) {
|
|
1633
|
+
const count = fs.readSync(fd, bytes, length, bytes.length - length, null);
|
|
1634
|
+
if (!count)
|
|
1635
|
+
break;
|
|
1636
|
+
length += count;
|
|
1637
|
+
}
|
|
1638
|
+
if (length > maxBytes)
|
|
1639
|
+
return unavailable("Apex source exceeds the bounded platform entry verification limit.");
|
|
1640
|
+
sourceText = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, length));
|
|
1641
|
+
}
|
|
1642
|
+
finally {
|
|
1643
|
+
fs.closeSync(fd);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
catch {
|
|
1648
|
+
return unavailable("Apex source is unavailable; platform entry evidence could not be verified.");
|
|
1649
|
+
}
|
|
1650
|
+
const source = { text: sourceText };
|
|
1651
|
+
let parser;
|
|
1652
|
+
let tree = null;
|
|
1653
|
+
try {
|
|
1654
|
+
const language = await getLanguageForFile(path.join(repoPath, relativePath));
|
|
1655
|
+
if (!language)
|
|
1656
|
+
return unavailable("Apex parser capability is unavailable.");
|
|
1657
|
+
parser = new Parser();
|
|
1658
|
+
parser.setLanguage(language);
|
|
1659
|
+
tree = parser.parse(source.text);
|
|
1660
|
+
if (!tree || tree.rootNode.hasError())
|
|
1661
|
+
return unavailable("Apex source is parse-partial; platform entry evidence is not established.");
|
|
1662
|
+
const nodes = [];
|
|
1663
|
+
const pending = [tree.rootNode];
|
|
1664
|
+
while (pending.length) {
|
|
1665
|
+
const node = pending.pop();
|
|
1666
|
+
if (!node)
|
|
1667
|
+
continue;
|
|
1668
|
+
if (node.type === "class_declaration" || node.type === "method_declaration")
|
|
1669
|
+
nodes.push(node);
|
|
1670
|
+
pending.push(...node.namedChildren);
|
|
1671
|
+
}
|
|
1672
|
+
const rows = db
|
|
1673
|
+
.query("SELECT * FROM symbols WHERE filePath=? AND kind IN ('class','method')")
|
|
1674
|
+
.all(relativePath);
|
|
1675
|
+
if (rows.length !== nodes.length)
|
|
1676
|
+
return unavailable("Indexed Apex declarations do not match source; repair the indexed scope.");
|
|
1677
|
+
const bySpan = new Map();
|
|
1678
|
+
const spanKey = (startLine, startCol, endLine, endCol) => `${startLine}:${startCol}:${endLine}:${endCol}`;
|
|
1679
|
+
for (const node of nodes) {
|
|
1680
|
+
const key = spanKey(node.startPosition.row + 1, node.startPosition.column, node.endPosition.row + 1, node.endPosition.column);
|
|
1681
|
+
bySpan.set(key, [...(bySpan.get(key) ?? []), node]);
|
|
1682
|
+
}
|
|
1683
|
+
const offsets = [...new Set(nodes.flatMap((node) => [node.startIndex, node.endIndex]))].sort((a, b) => a - b);
|
|
1684
|
+
const byteOffsets = new Map();
|
|
1685
|
+
let previousOffset = 0;
|
|
1686
|
+
let byteOffset = 0;
|
|
1687
|
+
for (const offset of offsets) {
|
|
1688
|
+
byteOffset += Buffer.byteLength(source.text.slice(previousOffset, offset));
|
|
1689
|
+
byteOffsets.set(offset, byteOffset);
|
|
1690
|
+
previousOffset = offset;
|
|
1691
|
+
}
|
|
1692
|
+
const declarations = [];
|
|
1693
|
+
const spans = new Map();
|
|
1694
|
+
const used = new Set();
|
|
1695
|
+
for (const row of rows) {
|
|
1696
|
+
const stored = parseApexDeclaration(row.apexDeclaration);
|
|
1697
|
+
if (!stored)
|
|
1698
|
+
return unavailable("Indexed Apex declaration capability is missing or invalid; repair the indexed scope.");
|
|
1699
|
+
const candidates = bySpan.get(spanKey(row.startLine, row.startCol, row.endLine, row.endCol)) ?? [];
|
|
1700
|
+
if (candidates.length !== 1 || used.has(candidates[0].id))
|
|
1701
|
+
return unavailable("Indexed Apex declaration spans are ambiguous or stale; repair the indexed scope.");
|
|
1702
|
+
const node = candidates[0];
|
|
1703
|
+
const actual = apexDeclaration(node);
|
|
1704
|
+
if (actual.name !== row.name ||
|
|
1705
|
+
actual.kind !== row.kind ||
|
|
1706
|
+
!isDeepStrictEqual(stored, actual))
|
|
1707
|
+
return unavailable("Indexed Apex declaration metadata differs from source; repair the indexed scope.");
|
|
1708
|
+
used.add(node.id);
|
|
1709
|
+
const key = String(row.id);
|
|
1710
|
+
declarations.push({ key, file: row.filePath, declaration: actual });
|
|
1711
|
+
spans.set(key, {
|
|
1712
|
+
identity: symbolIdentity(repoPath, row),
|
|
1713
|
+
file,
|
|
1714
|
+
startLine: row.startLine,
|
|
1715
|
+
startColumn: row.startCol + 1,
|
|
1716
|
+
endLine: row.endLine,
|
|
1717
|
+
endColumn: row.endCol + 1,
|
|
1718
|
+
startByte: byteOffsets.get(node.startIndex) ?? 0,
|
|
1719
|
+
endByte: byteOffsets.get(node.endIndex) ?? 0,
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
const rowsById = new Map(rows.map((row) => [row.id, row]));
|
|
1723
|
+
const declarationsByKey = new Map(declarations.map((row) => [row.key, row]));
|
|
1724
|
+
const ownerCounts = new Map();
|
|
1725
|
+
for (const row of declarations)
|
|
1726
|
+
if (row.declaration.kind === "class") {
|
|
1727
|
+
const key = row.declaration.owner.toLowerCase();
|
|
1728
|
+
ownerCounts.set(key, (ownerCounts.get(key) ?? 0) + 1);
|
|
1729
|
+
}
|
|
1730
|
+
const entries = apexPlatformEntryPoints(declarations);
|
|
1731
|
+
const sourceHash = crypto.createHash("sha256").update(source.text).digest("hex");
|
|
1732
|
+
return (selected) => {
|
|
1733
|
+
const currentSelected = rowsById.get(selected.id);
|
|
1734
|
+
if (!currentSelected || !isDeepStrictEqual(currentSelected, selected))
|
|
1735
|
+
return unavailable("Selected Apex identity changed during verification; retry the query.")(selected);
|
|
1736
|
+
// Even a selected class cannot establish a unique platform owner through duplicate declarations.
|
|
1737
|
+
const selectedDeclaration = declarationsByKey.get(String(selected.id));
|
|
1738
|
+
if (!selectedDeclaration)
|
|
1739
|
+
return unavailable("Selected Apex declaration is unavailable in the verified source.")(selected);
|
|
1740
|
+
if (ownerCounts.get(selectedDeclaration.declaration.owner.toLowerCase()) !== 1)
|
|
1741
|
+
return unavailable("Apex enclosing declaration is ambiguous; platform entry evidence is not established.")(selected);
|
|
1742
|
+
const entry = entries.get(String(selected.id));
|
|
1743
|
+
if (!entry)
|
|
1744
|
+
return undefined;
|
|
1745
|
+
const declaration = spans.get(String(selected.id));
|
|
1746
|
+
const owner = entry.ownerKey ? spans.get(entry.ownerKey) : undefined;
|
|
1747
|
+
if (!declaration || (entry.ownerKey && !owner))
|
|
1748
|
+
return unavailable("Apex platform declaration source correspondence is unavailable.")(selected);
|
|
1749
|
+
return {
|
|
1750
|
+
...base,
|
|
1751
|
+
status: "declared",
|
|
1752
|
+
reason: entry.reason,
|
|
1753
|
+
contracts: entry.contracts,
|
|
1754
|
+
sourceHash,
|
|
1755
|
+
declaration,
|
|
1756
|
+
...(owner ? { owner } : {}),
|
|
1757
|
+
};
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
catch {
|
|
1761
|
+
return unavailable("Apex platform entry verification failed; repair or inspect the indexed scope.");
|
|
1762
|
+
}
|
|
1763
|
+
finally {
|
|
1764
|
+
if (tree)
|
|
1765
|
+
freeQuietly(tree);
|
|
1766
|
+
if (parser)
|
|
1767
|
+
freeQuietly(parser);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
async function explainPlatformEntry(db, repoPath, selected, file) {
|
|
1771
|
+
if (!/\.cls$/i.test(selected.filePath) || !["class", "method"].includes(selected.kind))
|
|
1772
|
+
return undefined;
|
|
1773
|
+
return (await verifyPlatformEntryFile(db, repoPath, selected.filePath, file))(selected);
|
|
1774
|
+
}
|
|
1775
|
+
async function queryPlatformEntries(db, repoPath, selected, cap, incompleteDeclarations) {
|
|
1776
|
+
const entryLimit = Math.min(cap, 25), fileLimit = 8, fileByteLimit = 1024 * 1024;
|
|
1777
|
+
const collection = {
|
|
1778
|
+
scope: "classified-indexed-declarations",
|
|
1779
|
+
totalClassified: selected.length,
|
|
1780
|
+
returnedCount: 0,
|
|
1781
|
+
verifiedCount: 0,
|
|
1782
|
+
unavailableCount: 0,
|
|
1783
|
+
entryLimit,
|
|
1784
|
+
omittedCount: 0,
|
|
1785
|
+
truncated: false,
|
|
1786
|
+
partial: incompleteDeclarations,
|
|
1787
|
+
continuation: null,
|
|
1788
|
+
omissions: [],
|
|
1789
|
+
work: { fileLimit, fileByteLimit, attemptedFiles: 0, reservedSourceBytes: 0 },
|
|
1790
|
+
entries: [],
|
|
1791
|
+
};
|
|
1792
|
+
const files = new Map();
|
|
1793
|
+
const preview = [...selected]
|
|
1794
|
+
.sort((a, b) => compareBytes(a.filePath, b.filePath) ||
|
|
1795
|
+
a.startLine - b.startLine ||
|
|
1796
|
+
a.startCol - b.startCol ||
|
|
1797
|
+
compareBytes(a.identity ?? "", b.identity ?? ""))
|
|
1798
|
+
.slice(0, entryLimit);
|
|
1799
|
+
for (const row of preview) {
|
|
1800
|
+
// Hydrate only the bounded preview, retaining the original inventory snapshot
|
|
1801
|
+
// as a guard against row reuse or changed declaration metadata.
|
|
1802
|
+
const hydrated = db.query("SELECT * FROM symbols WHERE id=?").get(row.id);
|
|
1803
|
+
const corresponds = hydrated &&
|
|
1804
|
+
Object.entries(row).every(([key, value]) => isDeepStrictEqual(hydrated[key], value));
|
|
1805
|
+
let select = files.get(row.filePath);
|
|
1806
|
+
if (!select) {
|
|
1807
|
+
if (files.size >= fileLimit) {
|
|
1808
|
+
collection.omissions.push("Proof work limit reached; narrow the path or use explain for omitted declarations.");
|
|
1809
|
+
break;
|
|
1810
|
+
}
|
|
1811
|
+
collection.work.attemptedFiles++;
|
|
1812
|
+
collection.work.reservedSourceBytes += fileByteLimit;
|
|
1813
|
+
select = await verifyPlatformEntryFile(db, repoPath, row.filePath, row.filePath);
|
|
1814
|
+
files.set(row.filePath, select);
|
|
1815
|
+
}
|
|
1816
|
+
const proof = (corresponds ? select(hydrated) : undefined) ?? {
|
|
1817
|
+
version: 1,
|
|
1818
|
+
platform: "salesforce",
|
|
1819
|
+
runtimeInvocation: "not-established",
|
|
1820
|
+
status: "unavailable",
|
|
1821
|
+
reason: "Indexed platform classification was not confirmed by source verification; repair the indexed scope.",
|
|
1822
|
+
};
|
|
1823
|
+
collection.entries.push({
|
|
1824
|
+
identity: symbolIdentity(repoPath, row),
|
|
1825
|
+
symbol: row.name,
|
|
1826
|
+
file: row.filePath,
|
|
1827
|
+
line: row.startLine,
|
|
1828
|
+
platformEntry: proof,
|
|
1829
|
+
});
|
|
1830
|
+
if (proof.status === "declared")
|
|
1831
|
+
collection.verifiedCount++;
|
|
1832
|
+
else
|
|
1833
|
+
collection.unavailableCount++;
|
|
1834
|
+
}
|
|
1835
|
+
collection.returnedCount = collection.entries.length;
|
|
1836
|
+
collection.omittedCount = collection.totalClassified - collection.returnedCount;
|
|
1837
|
+
collection.truncated = collection.omittedCount > 0;
|
|
1838
|
+
collection.partial ||= collection.truncated || collection.unavailableCount > 0;
|
|
1839
|
+
if (selected.length > entryLimit)
|
|
1840
|
+
collection.omissions.push("Entry preview limit reached; narrow the path or use explain. This collection has no cursor.");
|
|
1841
|
+
if (incompleteDeclarations)
|
|
1842
|
+
collection.omissions.push("Indexed declaration capability is incomplete; totalClassified does not count unknown platform entries.");
|
|
1843
|
+
return collection;
|
|
1844
|
+
}
|
|
1536
1845
|
function resolveSymbolRows(db, repoPath, name, selector = {}) {
|
|
1537
1846
|
let rows = db.query("SELECT * FROM symbols WHERE name = ?").all(name);
|
|
1538
1847
|
if (selector.file)
|
|
@@ -7972,11 +8281,33 @@ const SPECIAL_ROUTE_EXTENSIONS = [
|
|
|
7972
8281
|
".prisma",
|
|
7973
8282
|
];
|
|
7974
8283
|
const extractionCoverage = new AsyncLocalStorage();
|
|
8284
|
+
const indexedSourceProofs = new WeakMap();
|
|
7975
8285
|
async function indexFile(...args) {
|
|
7976
8286
|
const [, , , db] = args;
|
|
7977
8287
|
if (!coverageContains(requireIndexCoverage(coverageForDatabase(db)), args[1]))
|
|
7978
8288
|
throw new Error(`knodin: refusing to index outside declared coverage: ${args[1]}`);
|
|
7979
|
-
|
|
8289
|
+
// Invalidate before graph writes too: a failed extraction must not leave an
|
|
8290
|
+
// older repair proof attached to partially replaced graph data.
|
|
8291
|
+
db.run("DELETE FROM meta WHERE key=?", [`${REPAIR_CHECKPOINT_PREFIX}${args[1]}`]);
|
|
8292
|
+
indexedSourceProofs.get(db)?.delete(args[1]);
|
|
8293
|
+
// Per-file proof binds actual extraction even before an aggregate epoch is
|
|
8294
|
+
// established. It does not certify a completed clean/scoped repository build.
|
|
8295
|
+
const bytes = !repairSourceAudit.getStore() ? Buffer.alloc(64 * 1024) : null;
|
|
8296
|
+
const sourceHash = bytes ? repairSourceFingerprint(args[2], args[1], bytes) : null;
|
|
8297
|
+
const extracted = await extractionCoverage.run(db, () => indexFileInCoverage(...args));
|
|
8298
|
+
// Unparsed/skipped are completed source reads, not successful graph writes.
|
|
8299
|
+
// Only the error sentinel false is ineligible for consumed-source proof.
|
|
8300
|
+
if (extracted !== false && sourceHash !== null && bytes) {
|
|
8301
|
+
if (repairSourceFingerprint(args[2], args[1], bytes) !== sourceHash)
|
|
8302
|
+
throw new Error(`Source changed during indexing: ${args[1]}; source proof not published.`);
|
|
8303
|
+
let proofs = indexedSourceProofs.get(db);
|
|
8304
|
+
if (!proofs) {
|
|
8305
|
+
proofs = new Map();
|
|
8306
|
+
indexedSourceProofs.set(db, proofs);
|
|
8307
|
+
}
|
|
8308
|
+
proofs.set(args[1], sourceHash);
|
|
8309
|
+
}
|
|
8310
|
+
return extracted !== false;
|
|
7980
8311
|
}
|
|
7981
8312
|
async function indexFileInCoverage(absolutePath, relativePath, repoPath, db, unparsed, collectResult) {
|
|
7982
8313
|
try {
|
|
@@ -8034,13 +8365,13 @@ async function indexFileInCoverage(absolutePath, relativePath, repoPath, db, unp
|
|
|
8034
8365
|
if (extraction.kind === "unparsed") {
|
|
8035
8366
|
if (unparsed)
|
|
8036
8367
|
tallyOne(unparsed, extensionBucket(relativePath));
|
|
8037
|
-
return;
|
|
8368
|
+
return "unparsed";
|
|
8038
8369
|
}
|
|
8039
8370
|
// A parse failure abandons this one file without tallying it as a
|
|
8040
8371
|
// coverage gap: the file IS supported, it just did not parse, and
|
|
8041
8372
|
// `extractGenericFileForIndex` has already warned about it.
|
|
8042
8373
|
if (extraction.kind === "skipped")
|
|
8043
|
-
return;
|
|
8374
|
+
return "skipped";
|
|
8044
8375
|
if (extraction.kind === "special") {
|
|
8045
8376
|
const { content, parseContent, tree } = extraction;
|
|
8046
8377
|
// The tree outlived the function that parsed it, so this is the only
|
|
@@ -8093,6 +8424,7 @@ async function indexFileInCoverage(absolutePath, relativePath, repoPath, db, unp
|
|
|
8093
8424
|
console.error(`Error indexing file ${relativePath}:`, error);
|
|
8094
8425
|
if (unparsed)
|
|
8095
8426
|
tallyOne(unparsed, extensionBucket(relativePath));
|
|
8427
|
+
return false;
|
|
8096
8428
|
}
|
|
8097
8429
|
}
|
|
8098
8430
|
/**
|
|
@@ -8824,34 +9156,43 @@ function sumCounts(counts) {
|
|
|
8824
9156
|
* costs time, versus resuming onto the wrong tree, which produces a graph that
|
|
8825
9157
|
* never described any single state of the repository and then promotes it.
|
|
8826
9158
|
*
|
|
8827
|
-
*
|
|
8828
|
-
*
|
|
9159
|
+
* Nonselected DB-bearing candidates are preserved. Selection is not deletion
|
|
9160
|
+
* authority; only the owner/policy-fenced non-DB residue sweep may clean up.
|
|
8829
9161
|
*/
|
|
8830
9162
|
function findResumableCandidate(repoPath) {
|
|
8831
9163
|
const head = gitHead(repoPath) ?? "";
|
|
8832
|
-
//
|
|
8833
|
-
//
|
|
8834
|
-
// exactly what a promoted candidate leaves behind (KNODIN-2). Without this,
|
|
8835
|
-
// the comment above about not accumulating abandoned copies was false for the
|
|
8836
|
-
// most common case.
|
|
9164
|
+
// Only the policy/owner-fenced sweep cleans non-DB residue. Selection below
|
|
9165
|
+
// never borrows deletion authority for DB-bearing candidates.
|
|
8837
9166
|
sweepAbandonedCandidates(repoPath);
|
|
8838
9167
|
let resumable = null;
|
|
9168
|
+
let preserved = 0;
|
|
8839
9169
|
for (const candidate of listCandidates(repoPath)) {
|
|
8840
9170
|
let usable = false;
|
|
8841
9171
|
try {
|
|
8842
|
-
|
|
9172
|
+
assertCandidateResumeOwner(candidate);
|
|
9173
|
+
const bound = bindCandidateDatabase(repoPath, candidate);
|
|
8843
9174
|
try {
|
|
8844
|
-
|
|
8845
|
-
|
|
8846
|
-
|
|
9175
|
+
const probe = new Database(candidate.databasePath, { readonly: true });
|
|
9176
|
+
try {
|
|
9177
|
+
bound.verify();
|
|
9178
|
+
usable =
|
|
9179
|
+
getMeta(probe, CLEAN_INDEX_IN_PROGRESS_META_KEY) === "1" &&
|
|
9180
|
+
getMeta(probe, CLEAN_INDEX_HEAD_META_KEY) === head &&
|
|
9181
|
+
// Older interrupted builds have no source-start evidence. Restart
|
|
9182
|
+
// them rather than inventing a new epoch for already indexed rows.
|
|
9183
|
+
validSourceEpoch(getMeta(probe, CLEAN_INDEX_SOURCE_START_KEY)) !== null;
|
|
9184
|
+
}
|
|
9185
|
+
finally {
|
|
9186
|
+
probe.close();
|
|
9187
|
+
}
|
|
9188
|
+
bound.verify();
|
|
8847
9189
|
}
|
|
8848
9190
|
finally {
|
|
8849
|
-
|
|
9191
|
+
bound.close();
|
|
8850
9192
|
}
|
|
8851
9193
|
}
|
|
8852
9194
|
catch {
|
|
8853
|
-
// Unreadable
|
|
8854
|
-
// and not worth keeping.
|
|
9195
|
+
// Unreadable or ownership/source proof unavailable: preserve, do not adopt.
|
|
8855
9196
|
usable = false;
|
|
8856
9197
|
}
|
|
8857
9198
|
// Keep only the newest usable one. An older survivor is from an even
|
|
@@ -8860,14 +9201,11 @@ function findResumableCandidate(repoPath) {
|
|
|
8860
9201
|
resumable = candidate;
|
|
8861
9202
|
continue;
|
|
8862
9203
|
}
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
}
|
|
8866
|
-
catch {
|
|
8867
|
-
// Best effort: failing to clean up a stale candidate must not stop the
|
|
8868
|
-
// rebuild that is about to replace it.
|
|
8869
|
-
}
|
|
9204
|
+
// Preserve older, unknown, live and unusable candidates for explicit audit.
|
|
9205
|
+
preserved++;
|
|
8870
9206
|
}
|
|
9207
|
+
if (preserved)
|
|
9208
|
+
console.error(`knodin storage: preserved ${preserved} nonselected DB-bearing candidate(s); inspect candidate storage explicitly before cleanup.`);
|
|
8871
9209
|
return resumable;
|
|
8872
9210
|
}
|
|
8873
9211
|
/** Write the coverage tally as it stands. Safe to call repeatedly mid-index. */
|
|
@@ -8984,7 +9322,38 @@ function isIndexableJsonCandidate(file, repoPath) {
|
|
|
8984
9322
|
if (path.basename(file).toLowerCase() !== "manifest.json")
|
|
8985
9323
|
return false;
|
|
8986
9324
|
try {
|
|
8987
|
-
|
|
9325
|
+
const target = path.join(repoPath, file);
|
|
9326
|
+
if (!safeCoveragePath(repoPath, file))
|
|
9327
|
+
return false;
|
|
9328
|
+
const checked = fs.lstatSync(target);
|
|
9329
|
+
if (!checked.isFile() || checked.isSymbolicLink())
|
|
9330
|
+
return false;
|
|
9331
|
+
const descriptor = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
9332
|
+
try {
|
|
9333
|
+
const opened = fs.fstatSync(descriptor);
|
|
9334
|
+
if (opened.dev !== checked.dev || opened.ino !== checked.ino || opened.size !== checked.size)
|
|
9335
|
+
return false;
|
|
9336
|
+
const value = fs.readFileSync(descriptor, "utf-8");
|
|
9337
|
+
const final = fs.fstatSync(descriptor);
|
|
9338
|
+
if (!safeCoveragePath(repoPath, file))
|
|
9339
|
+
return false;
|
|
9340
|
+
const current = fs.lstatSync(target);
|
|
9341
|
+
if (!current.isFile() ||
|
|
9342
|
+
current.isSymbolicLink() ||
|
|
9343
|
+
current.dev !== opened.dev ||
|
|
9344
|
+
current.ino !== opened.ino ||
|
|
9345
|
+
current.size !== opened.size ||
|
|
9346
|
+
current.mtimeMs !== opened.mtimeMs ||
|
|
9347
|
+
current.ctimeMs !== opened.ctimeMs ||
|
|
9348
|
+
final.size !== opened.size ||
|
|
9349
|
+
final.mtimeMs !== opened.mtimeMs ||
|
|
9350
|
+
final.ctimeMs !== opened.ctimeMs)
|
|
9351
|
+
return false;
|
|
9352
|
+
return isDbtManifest(value);
|
|
9353
|
+
}
|
|
9354
|
+
finally {
|
|
9355
|
+
fs.closeSync(descriptor);
|
|
9356
|
+
}
|
|
8988
9357
|
}
|
|
8989
9358
|
catch {
|
|
8990
9359
|
// Deliberate (comment kept from main's equivalent, removed in this merge):
|
|
@@ -9020,9 +9389,7 @@ function gitTrackedCandidates(repoPath, intent) {
|
|
|
9020
9389
|
if (intent && !coverageContains(intent, file))
|
|
9021
9390
|
return false;
|
|
9022
9391
|
try {
|
|
9023
|
-
return
|
|
9024
|
-
? (safeCoveragePath(repoPath, file)?.isFile() ?? false)
|
|
9025
|
-
: fs.statSync(path.join(repoPath, file)).isFile();
|
|
9392
|
+
return safeCoveragePath(repoPath, file)?.isFile() ?? false;
|
|
9026
9393
|
}
|
|
9027
9394
|
catch {
|
|
9028
9395
|
return false;
|
|
@@ -9082,9 +9449,7 @@ async function collectRepoFilesWithCoverageAbortable(repoPath, signal, db) {
|
|
|
9082
9449
|
continue;
|
|
9083
9450
|
if (candidates) {
|
|
9084
9451
|
try {
|
|
9085
|
-
if (!(
|
|
9086
|
-
? safeCoveragePath(repoPath, file)?.isFile()
|
|
9087
|
-
: fs.statSync(path.join(repoPath, file)).isFile()))
|
|
9452
|
+
if (!safeCoveragePath(repoPath, file)?.isFile())
|
|
9088
9453
|
continue;
|
|
9089
9454
|
}
|
|
9090
9455
|
catch {
|
|
@@ -9162,6 +9527,29 @@ function gitHead(repoPath) {
|
|
|
9162
9527
|
return null;
|
|
9163
9528
|
}
|
|
9164
9529
|
}
|
|
9530
|
+
/** Git proof is required at repository roots, but not at verified nested paths. */
|
|
9531
|
+
function hasGitCheckout(repoPath) {
|
|
9532
|
+
// A local marker includes unborn repositories and worktree .git files. Do
|
|
9533
|
+
// not let a failed root probe downgrade either to the stat-only fallback.
|
|
9534
|
+
if (fs.existsSync(path.join(repoPath, ".git")))
|
|
9535
|
+
return true;
|
|
9536
|
+
try {
|
|
9537
|
+
const root = child_process
|
|
9538
|
+
.execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
9539
|
+
cwd: repoPath,
|
|
9540
|
+
encoding: "utf8",
|
|
9541
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
9542
|
+
})
|
|
9543
|
+
.trim();
|
|
9544
|
+
if (root)
|
|
9545
|
+
return isSameDir(root, repoPath);
|
|
9546
|
+
}
|
|
9547
|
+
catch {
|
|
9548
|
+
// Only a successful root mismatch permits the documented nested-path
|
|
9549
|
+
// fallback. A failed probe with other Git evidence remains unverifiable.
|
|
9550
|
+
}
|
|
9551
|
+
return gitHead(repoPath) !== null;
|
|
9552
|
+
}
|
|
9165
9553
|
/** Read a value from the `meta` key/value table (null if absent). */
|
|
9166
9554
|
function getMeta(db, key) {
|
|
9167
9555
|
try {
|
|
@@ -9304,9 +9692,11 @@ function freshnessFingerprint(value) {
|
|
|
9304
9692
|
return null;
|
|
9305
9693
|
return crypto.createHash("sha256").update(value).digest("hex");
|
|
9306
9694
|
}
|
|
9307
|
-
function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProbe) {
|
|
9695
|
+
function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProbe, deepSourceVerified = false) {
|
|
9308
9696
|
const coverage = coverageForDatabase(db);
|
|
9309
|
-
const git = gitProbe === undefined
|
|
9697
|
+
const git = gitProbe === undefined
|
|
9698
|
+
? gitWorkTreeProbe(repoPath, coverage.intent ?? undefined, db)
|
|
9699
|
+
: gitProbe;
|
|
9310
9700
|
const intent = coverage.intent;
|
|
9311
9701
|
const scopedDirty = intent
|
|
9312
9702
|
? git?.dirty.filter((file) => coverageContains(intent, file))
|
|
@@ -9319,19 +9709,34 @@ function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProb
|
|
|
9319
9709
|
const workingTreeChanged = git !== null &&
|
|
9320
9710
|
isSameDir(git.root, repoPath) &&
|
|
9321
9711
|
baseline !== null &&
|
|
9322
|
-
baseline !==
|
|
9712
|
+
(baseline !==
|
|
9713
|
+
(scopedDirty ? coverageDirtyFingerprint(coverage.intent, scopedDirty) : undefined) ||
|
|
9714
|
+
(db ? (getMeta(db, "lastProbePolicy") ?? "") : "") !== git.policyFingerprint);
|
|
9715
|
+
const sourceProofUnavailable = db !== null &&
|
|
9716
|
+
hasGitCheckout(repoPath) &&
|
|
9717
|
+
(validSourceEpoch(getMeta(db, CONTENT_PROOF_EPOCH_KEY)) === null ||
|
|
9718
|
+
git === null ||
|
|
9719
|
+
(!deepSourceVerified && !git.statProofReliable) ||
|
|
9720
|
+
(!deepSourceVerified &&
|
|
9721
|
+
(!getMeta(db, "lastProbeContent") ||
|
|
9722
|
+
dirtyContentFingerprint(repoPath, scopedDirty ?? []) !==
|
|
9723
|
+
getMeta(db, "lastProbeContent"))));
|
|
9323
9724
|
const state = stateOverride ??
|
|
9324
|
-
(
|
|
9325
|
-
?
|
|
9326
|
-
|
|
9327
|
-
: "fresh"
|
|
9328
|
-
: relation.commitRelation === "unknown"
|
|
9725
|
+
(sourceProofUnavailable || (git === null && hasGitCheckout(repoPath))
|
|
9726
|
+
? "unknown"
|
|
9727
|
+
: git !== null && baseline === null
|
|
9329
9728
|
? "unknown"
|
|
9330
|
-
:
|
|
9331
|
-
?
|
|
9332
|
-
: workingTreeChanged
|
|
9729
|
+
: currentHead === null && indexedHead === null && baseline !== null
|
|
9730
|
+
? workingTreeChanged
|
|
9333
9731
|
? "stale-working-tree"
|
|
9334
|
-
: "fresh"
|
|
9732
|
+
: "fresh"
|
|
9733
|
+
: relation.commitRelation === "unknown"
|
|
9734
|
+
? "unknown"
|
|
9735
|
+
: relation.commitRelation !== "equal"
|
|
9736
|
+
? "stale-head"
|
|
9737
|
+
: workingTreeChanged
|
|
9738
|
+
? "stale-working-tree"
|
|
9739
|
+
: "fresh");
|
|
9335
9740
|
return {
|
|
9336
9741
|
currentHead,
|
|
9337
9742
|
indexCoverage: indexCoverageSummary(coverage),
|
|
@@ -9359,9 +9764,17 @@ function buildFreshnessEnvelope(repoPath, db, verifiedAt, stateOverride, gitProb
|
|
|
9359
9764
|
* when the file could not be stat'd, which the caller adds harmlessly.
|
|
9360
9765
|
*/
|
|
9361
9766
|
function recordIndexState(db, repoPath, relPath) {
|
|
9767
|
+
// A normal indexing write supersedes any interrupted repair's content proof,
|
|
9768
|
+
// even when the replacement source happens to preserve its stat tuple.
|
|
9769
|
+
db.run("DELETE FROM meta WHERE key=?", [`${REPAIR_CHECKPOINT_PREFIX}${relPath}`]);
|
|
9362
9770
|
try {
|
|
9363
9771
|
const st = fs.statSync(path.join(repoPath, relPath));
|
|
9364
9772
|
db.run("INSERT INTO index_state(filePath, mtimeMs, size) VALUES (?, ?, ?) ON CONFLICT(filePath) DO UPDATE SET mtimeMs = excluded.mtimeMs, size = excluded.size", [relPath, st.mtimeMs, st.size]);
|
|
9773
|
+
const sourceHash = indexedSourceProofs.get(db)?.get(relPath);
|
|
9774
|
+
indexedSourceProofs.get(db)?.delete(relPath);
|
|
9775
|
+
if (sourceHash) {
|
|
9776
|
+
setMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${relPath}`, JSON.stringify({ version: 1, sourceHash, mtimeMs: st.mtimeMs, size: st.size }));
|
|
9777
|
+
}
|
|
9365
9778
|
return st.size;
|
|
9366
9779
|
}
|
|
9367
9780
|
catch (_) {
|
|
@@ -9417,6 +9830,7 @@ function shouldPurgeMissingFile(db) {
|
|
|
9417
9830
|
function removeIndexState(db, relPath) {
|
|
9418
9831
|
if (!shouldPurgeMissingFile(db))
|
|
9419
9832
|
return;
|
|
9833
|
+
db.run("DELETE FROM meta WHERE key=?", [`${REPAIR_CHECKPOINT_PREFIX}${relPath}`]);
|
|
9420
9834
|
try {
|
|
9421
9835
|
db.run("DELETE FROM apex_consumer_files WHERE filePath = ?", [relPath]);
|
|
9422
9836
|
db.run("DELETE FROM apex_class_uses WHERE filePath = ?", [relPath]);
|
|
@@ -9464,9 +9878,9 @@ function parseGitStatusPaths(output) {
|
|
|
9464
9878
|
* incremental), so a clean reconcile does no work.
|
|
9465
9879
|
*/
|
|
9466
9880
|
/**
|
|
9467
|
-
* Full mtime/size comparison against `index_state`, used
|
|
9468
|
-
*
|
|
9469
|
-
*
|
|
9881
|
+
* Full mtime/size comparison against `index_state`, used only for verified
|
|
9882
|
+
* non-Git or nested paths. Failed Git content enumeration refuses reconciliation
|
|
9883
|
+
* rather than downgrading to this stat-only comparison. Not a substitute for
|
|
9470
9884
|
* the git fast path in general: it only detects drift relative to *this*
|
|
9471
9885
|
* checkout's own recorded state, so a database whose `index_state` rows came
|
|
9472
9886
|
* from a different physical checkout (fresh mtimes on every file) degrades
|
|
@@ -9499,25 +9913,23 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = getMeta(d
|
|
|
9499
9913
|
const coverage = requireIndexCoverage(coverageForDatabase(db));
|
|
9500
9914
|
progress?.("collecting-files", 0, "Checking existing local graph for changes");
|
|
9501
9915
|
const changed = new Set();
|
|
9916
|
+
// Git has already content-proved these paths changed. Do not narrow that
|
|
9917
|
+
// evidence back to mtime/size, which Git's stat cache can legitimately
|
|
9918
|
+
// consider unchanged after a same-size edit with a restored timestamp.
|
|
9919
|
+
const gitChanged = new Set();
|
|
9502
9920
|
const head = gitHead(repoPath);
|
|
9503
9921
|
let canonicalFiles;
|
|
9504
|
-
if (
|
|
9922
|
+
if (hasGitCheckout(repoPath)) {
|
|
9505
9923
|
// --- git fast path ---
|
|
9506
9924
|
const lastHead = getMeta(db, "lastIndexedHead");
|
|
9507
|
-
|
|
9508
|
-
|
|
9509
|
-
|
|
9510
|
-
|
|
9511
|
-
|
|
9512
|
-
|
|
9513
|
-
});
|
|
9514
|
-
for (const file of parseGitStatusPaths(status))
|
|
9515
|
-
changed.add(file);
|
|
9516
|
-
}
|
|
9925
|
+
const workTree = gitWorkTreeProbe(repoPath, coverage, db);
|
|
9926
|
+
if (!workTree || workTree.head !== (head ?? ""))
|
|
9927
|
+
throw new Error("Incomplete Git working-tree enumeration; reconciliation baseline unchanged");
|
|
9928
|
+
for (const file of workTree.dirty) {
|
|
9929
|
+
changed.add(file);
|
|
9930
|
+
gitChanged.add(file);
|
|
9517
9931
|
}
|
|
9518
|
-
|
|
9519
|
-
let baselineUnresolved = false;
|
|
9520
|
-
if (lastHead && lastHead !== head) {
|
|
9932
|
+
if (head && lastHead && lastHead !== head) {
|
|
9521
9933
|
try {
|
|
9522
9934
|
for (const pathspecs of coverageGitPathspecBatches(coverage)) {
|
|
9523
9935
|
const diff = child_process.execFileSync("git", [
|
|
@@ -9531,30 +9943,19 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = getMeta(d
|
|
|
9531
9943
|
cwd: repoPath,
|
|
9532
9944
|
encoding: "utf-8",
|
|
9533
9945
|
stdio: ["ignore", "pipe", "ignore"],
|
|
9946
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
9534
9947
|
});
|
|
9535
|
-
for (const file of splitNulPaths(diff))
|
|
9948
|
+
for (const file of splitNulPaths(diff)) {
|
|
9536
9949
|
changed.add(file);
|
|
9950
|
+
gitChanged.add(file);
|
|
9951
|
+
}
|
|
9537
9952
|
}
|
|
9538
9953
|
}
|
|
9539
|
-
catch (
|
|
9540
|
-
//
|
|
9541
|
-
//
|
|
9542
|
-
//
|
|
9543
|
-
|
|
9544
|
-
// new HEAD below while having missed every real change in
|
|
9545
|
-
// between — fall back to a full comparison instead.
|
|
9546
|
-
baselineUnresolved = true;
|
|
9547
|
-
}
|
|
9548
|
-
}
|
|
9549
|
-
if (baselineUnresolved) {
|
|
9550
|
-
console.warn(`knodin: cached baseline commit ${lastHead} is not resolvable in this checkout (shallow clone or foreign database) — falling back to a full file-drift comparison`);
|
|
9551
|
-
canonicalFiles = collectRepoFiles(repoPath, db);
|
|
9552
|
-
if (canonicalFiles.length > 50000) {
|
|
9553
|
-
console.warn(`knodin: skipping full-drift fallback reconcile (${canonicalFiles.length} files exceeds 50k bound)`);
|
|
9554
|
-
}
|
|
9555
|
-
else {
|
|
9556
|
-
for (const file of detectDriftByStat(repoPath, db, canonicalFiles))
|
|
9557
|
-
changed.add(file);
|
|
9954
|
+
catch (error) {
|
|
9955
|
+
// A failed content diff (overflow, missing shallow history, or
|
|
9956
|
+
// foreign baseline) is not an empty diff. Stat-only comparison
|
|
9957
|
+
// cannot recover equal-stat edits, at any repository size.
|
|
9958
|
+
throw new Error("Incomplete Git commit-diff enumeration; run knodin index --clean to recover missing history", { cause: error });
|
|
9558
9959
|
}
|
|
9559
9960
|
}
|
|
9560
9961
|
}
|
|
@@ -9593,10 +9994,29 @@ async function reconcileIndex(repoPath, db, progress, skipEmbeddings = getMeta(d
|
|
|
9593
9994
|
});
|
|
9594
9995
|
let reindexed = 0;
|
|
9595
9996
|
const reindexedPaths = [];
|
|
9997
|
+
const readParsedProof = repairCheckpointReader(db, repoPath);
|
|
9596
9998
|
for (const [candidateIndex, rel] of candidates.entries()) {
|
|
9597
9999
|
const abs = path.join(repoPath, rel);
|
|
9598
10000
|
if (canonicalPaths.has(rel) && fs.existsSync(abs)) {
|
|
9599
|
-
|
|
10001
|
+
let forceExtraction = gitChanged.has(rel);
|
|
10002
|
+
if (forceExtraction) {
|
|
10003
|
+
try {
|
|
10004
|
+
const before = fs.statSync(abs);
|
|
10005
|
+
const proof = readParsedProof(rel);
|
|
10006
|
+
const after = fs.statSync(abs);
|
|
10007
|
+
forceExtraction =
|
|
10008
|
+
!proof?.matches ||
|
|
10009
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
10010
|
+
before.size !== after.size ||
|
|
10011
|
+
before.ctimeMs !== after.ctimeMs ||
|
|
10012
|
+
before.ino !== after.ino ||
|
|
10013
|
+
before.dev !== after.dev;
|
|
10014
|
+
}
|
|
10015
|
+
catch {
|
|
10016
|
+
forceExtraction = true;
|
|
10017
|
+
}
|
|
10018
|
+
}
|
|
10019
|
+
if (forceExtraction || fileDriftedFromIndexState(db, repoPath, rel)) {
|
|
9600
10020
|
await indexFile(abs, rel, repoPath, db);
|
|
9601
10021
|
recordIndexState(db, repoPath, rel);
|
|
9602
10022
|
reindexed++;
|
|
@@ -9778,82 +10198,204 @@ export function resetFreshnessStats() {
|
|
|
9778
10198
|
freshnessStats.probeOverflows = 0;
|
|
9779
10199
|
freshnessProbes.clear();
|
|
9780
10200
|
}
|
|
9781
|
-
|
|
9782
|
-
|
|
9783
|
-
|
|
9784
|
-
|
|
9785
|
-
|
|
9786
|
-
|
|
9787
|
-
|
|
9788
|
-
|
|
9789
|
-
|
|
9790
|
-
|
|
10201
|
+
function freshnessDirtyPaths(repoPath, paths, intent, db, standardExcludes = []) {
|
|
10202
|
+
const dirty = new Set();
|
|
10203
|
+
const policies = new Set(standardExcludes);
|
|
10204
|
+
// Share a total content-read budget between manifest eligibility and policy
|
|
10205
|
+
// proof. Oversized new manifests need explicit indexing, not an unbounded
|
|
10206
|
+
// read on every status/query. Already indexed paths do not need this read.
|
|
10207
|
+
const bytes = Buffer.alloc(64 * 1024);
|
|
10208
|
+
let remaining = 1024 * 1024;
|
|
10209
|
+
let manifests = 0;
|
|
10210
|
+
const indexedPath = db?.query("SELECT 1 AS present FROM index_state WHERE filePath=?");
|
|
10211
|
+
for (const file of paths) {
|
|
10212
|
+
if (path.posix.basename(file) === ".gitignore" && isIndexablePath(file)) {
|
|
10213
|
+
const directory = path.posix.dirname(file);
|
|
10214
|
+
if (!intent ||
|
|
10215
|
+
intent.mode === "full" ||
|
|
10216
|
+
directory === "." ||
|
|
10217
|
+
intent.files.some((candidate) => candidate.startsWith(`${directory}/`)) ||
|
|
10218
|
+
intent.prefixes.some((prefix) => prefix === directory ||
|
|
10219
|
+
prefix.startsWith(`${directory}/`) ||
|
|
10220
|
+
directory.startsWith(`${prefix}/`)))
|
|
10221
|
+
policies.add(path.resolve(repoPath, file));
|
|
10222
|
+
}
|
|
10223
|
+
if (intent && !coverageContains(intent, file))
|
|
10224
|
+
continue;
|
|
10225
|
+
// Previously indexed paths remain observable after deletion or policy changes.
|
|
10226
|
+
const indexed = indexedPath?.get(file);
|
|
10227
|
+
if (indexed) {
|
|
10228
|
+
dirty.add(file);
|
|
10229
|
+
continue;
|
|
10230
|
+
}
|
|
10231
|
+
if (!isIndexableSourcePath(file))
|
|
10232
|
+
continue;
|
|
9791
10233
|
try {
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
9795
|
-
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
if (
|
|
9802
|
-
|
|
9803
|
-
const
|
|
9804
|
-
|
|
9805
|
-
const
|
|
9806
|
-
|
|
9807
|
-
|
|
9808
|
-
|
|
10234
|
+
if (!safeCoveragePath(repoPath, file)?.isFile())
|
|
10235
|
+
continue;
|
|
10236
|
+
}
|
|
10237
|
+
catch {
|
|
10238
|
+
continue;
|
|
10239
|
+
}
|
|
10240
|
+
if (file.endsWith(".json") && !salesforceExperienceFile(file)) {
|
|
10241
|
+
if (path.basename(file).toLowerCase() !== "manifest.json")
|
|
10242
|
+
continue;
|
|
10243
|
+
if (++manifests > 256)
|
|
10244
|
+
throw new Error("Freshness manifest work limit exceeded");
|
|
10245
|
+
const fd = fs.openSync(path.join(repoPath, file), "r");
|
|
10246
|
+
try {
|
|
10247
|
+
const stat = fs.fstatSync(fd);
|
|
10248
|
+
if (!stat.isFile() || stat.size > remaining)
|
|
10249
|
+
throw new Error("Freshness manifest byte limit exceeded");
|
|
10250
|
+
const chunks = [];
|
|
10251
|
+
while (true) {
|
|
10252
|
+
const count = fs.readSync(fd, bytes, 0, Math.min(bytes.length, remaining + 1), null);
|
|
10253
|
+
if (!count)
|
|
10254
|
+
break;
|
|
10255
|
+
remaining -= count;
|
|
10256
|
+
if (remaining < 0)
|
|
10257
|
+
throw new Error("Freshness manifest byte limit exceeded");
|
|
10258
|
+
chunks.push(Buffer.from(bytes.subarray(0, count)));
|
|
10259
|
+
}
|
|
10260
|
+
if (!isDbtManifest(Buffer.concat(chunks).toString("utf8")))
|
|
10261
|
+
continue;
|
|
10262
|
+
}
|
|
10263
|
+
finally {
|
|
10264
|
+
fs.closeSync(fd);
|
|
9809
10265
|
}
|
|
9810
|
-
|
|
10266
|
+
}
|
|
10267
|
+
dirty.add(file);
|
|
10268
|
+
}
|
|
10269
|
+
if (policies.size > 256)
|
|
10270
|
+
throw new Error("Freshness ignore-policy work limit exceeded");
|
|
10271
|
+
const hash = crypto.createHash("sha256");
|
|
10272
|
+
// A pruning-policy change invalidates legacy baselines once, allowing the
|
|
10273
|
+
// canonical reconciler to remove old rows without enumerating pruned trees.
|
|
10274
|
+
hash.update(JSON.stringify(prunePolicyDescriptor())).update("\0");
|
|
10275
|
+
for (const file of [...policies].sort(compareBytes)) {
|
|
10276
|
+
hash.update(JSON.stringify(file)).update("\0");
|
|
10277
|
+
let fd;
|
|
10278
|
+
try {
|
|
10279
|
+
fd = fs.openSync(file, "r");
|
|
9811
10280
|
}
|
|
9812
10281
|
catch (error) {
|
|
9813
|
-
if (error.code === "
|
|
9814
|
-
|
|
9815
|
-
|
|
10282
|
+
if (error.code === "ENOENT") {
|
|
10283
|
+
hash.update("deleted\0");
|
|
10284
|
+
continue;
|
|
10285
|
+
}
|
|
10286
|
+
throw error;
|
|
10287
|
+
}
|
|
10288
|
+
try {
|
|
10289
|
+
const stat = fs.fstatSync(fd);
|
|
10290
|
+
// Git supports the platform null device to disable global excludes.
|
|
10291
|
+
// Do not generalize this exception to other devices or streams.
|
|
10292
|
+
if (file === os.devNull && stat.isCharacterDevice()) {
|
|
10293
|
+
hash.update("empty-null-policy\0");
|
|
10294
|
+
continue;
|
|
10295
|
+
}
|
|
10296
|
+
if (!stat.isFile() || stat.size > remaining)
|
|
10297
|
+
throw new Error("Freshness ignore-policy byte limit exceeded");
|
|
10298
|
+
let length = 0;
|
|
10299
|
+
const fileHash = crypto.createHash("sha256");
|
|
10300
|
+
while (true) {
|
|
10301
|
+
const count = fs.readSync(fd, bytes, 0, Math.min(bytes.length, remaining + 1), null);
|
|
10302
|
+
if (!count)
|
|
10303
|
+
break;
|
|
10304
|
+
length += count;
|
|
10305
|
+
remaining -= count;
|
|
10306
|
+
if (remaining < 0)
|
|
10307
|
+
throw new Error("Freshness ignore-policy byte limit exceeded");
|
|
10308
|
+
fileHash.update(bytes.subarray(0, count));
|
|
10309
|
+
}
|
|
10310
|
+
hash.update(`${length}:${fileHash.digest("hex")}\0`);
|
|
10311
|
+
}
|
|
10312
|
+
finally {
|
|
10313
|
+
fs.closeSync(fd);
|
|
9816
10314
|
}
|
|
9817
10315
|
}
|
|
9818
|
-
|
|
10316
|
+
return {
|
|
10317
|
+
dirty: [...dirty].sort(compareBytes),
|
|
10318
|
+
policyFingerprint: hash.digest("hex"),
|
|
10319
|
+
};
|
|
10320
|
+
}
|
|
10321
|
+
/**
|
|
10322
|
+
* Lossless Git file paths with canonical pruning applied before Git enumerates
|
|
10323
|
+
* untracked content. Source eligibility is then shared with the collector.
|
|
10324
|
+
*/
|
|
10325
|
+
function gitWorkTreeProbe(repoPath, intent, db) {
|
|
9819
10326
|
try {
|
|
9820
|
-
|
|
9821
|
-
.execSync("git rev-parse HEAD --show-toplevel && git status --porcelain", {
|
|
10327
|
+
const options = {
|
|
9822
10328
|
cwd: repoPath,
|
|
9823
|
-
encoding: "
|
|
9824
|
-
stdio: ["
|
|
9825
|
-
// Node's default 1 MB maxBuffer holds only a few thousand dirty
|
|
9826
|
-
// porcelain lines; a large dirty tree then threw here and silently
|
|
9827
|
-
// demoted the probe to the bounded mtime fallback (KNODIN-39).
|
|
10329
|
+
encoding: "utf8",
|
|
10330
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
9828
10331
|
maxBuffer: 64 * 1024 * 1024,
|
|
9829
|
-
}
|
|
9830
|
-
|
|
10332
|
+
};
|
|
10333
|
+
const root = child_process
|
|
10334
|
+
.execFileSync("git", ["rev-parse", "--show-toplevel"], options)
|
|
10335
|
+
.trim();
|
|
10336
|
+
if (!isSameDir(root, repoPath))
|
|
10337
|
+
return null;
|
|
10338
|
+
// Hooks may use the physical path while a CLI caller uses a symlink
|
|
10339
|
+
// alias (/var versus /private/var on macOS). Policy identities must
|
|
10340
|
+
// describe the same repository independently of that spelling.
|
|
10341
|
+
const canonicalRoot = fs.realpathSync(root);
|
|
10342
|
+
// Resolve Git's shared/worktree info directory and effective global ignore
|
|
10343
|
+
// source through Git itself, including config includes and ~ expansion.
|
|
10344
|
+
const infoExclude = child_process
|
|
10345
|
+
.execFileSync("git", ["rev-parse", "--git-path", "info/exclude"], options)
|
|
10346
|
+
.replace(/\n$/, "");
|
|
10347
|
+
const defaultExclude = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "git", "ignore");
|
|
10348
|
+
const globalExclude = child_process
|
|
10349
|
+
.execFileSync("git", ["config", "--path", "--null", "--default", defaultExclude, "--get", "core.excludesFile"], options)
|
|
10350
|
+
.replace(/\0$/, "");
|
|
10351
|
+
const standardExcludes = [path.resolve(canonicalRoot, infoExclude)];
|
|
10352
|
+
if (globalExclude)
|
|
10353
|
+
standardExcludes.push(path.resolve(canonicalRoot, globalExclude));
|
|
10354
|
+
const paths = new Set();
|
|
10355
|
+
// Porcelain can suppress a tracked policy's equal-stat edit. Enumerate
|
|
10356
|
+
// only tracked .gitignore paths, never the source tree, then hash their
|
|
10357
|
+
// bounded content independently of Git's stat-cache verdict.
|
|
10358
|
+
const trackedPolicies = child_process.execFileSync("git", ["ls-files", "-z", "--", ":(glob)**/.gitignore"], options);
|
|
10359
|
+
for (const file of trackedPolicies.split("\0"))
|
|
10360
|
+
if (file)
|
|
10361
|
+
paths.add(file);
|
|
10362
|
+
const statusHash = crypto.createHash("sha256");
|
|
10363
|
+
const trustCtime = child_process
|
|
10364
|
+
.execFileSync("git", ["config", "--type=bool", "--default=true", "--get", "core.trustctime"], options)
|
|
10365
|
+
.trim();
|
|
10366
|
+
const checkStat = child_process
|
|
10367
|
+
.execFileSync("git", ["config", "--default=default", "--get", "core.checkStat"], options)
|
|
10368
|
+
.trim()
|
|
10369
|
+
.toLowerCase();
|
|
10370
|
+
const prune = prunePolicyDescriptor();
|
|
10371
|
+
const excludes = [...prune.directories, ...prune.patterns].map((entry) => `:(exclude,glob)**/${entry}/**`);
|
|
10372
|
+
for (const pathspecs of coverageGitPathspecBatches(intent)) {
|
|
10373
|
+
const output = child_process.execFileSync("git", [
|
|
10374
|
+
"status",
|
|
10375
|
+
"--porcelain=v1",
|
|
10376
|
+
"-z",
|
|
10377
|
+
"--untracked-files=all",
|
|
10378
|
+
...(pathspecs.length ? pathspecs : ["--", "."]),
|
|
10379
|
+
":(glob)**/.gitignore",
|
|
10380
|
+
...excludes,
|
|
10381
|
+
], options);
|
|
10382
|
+
statusHash.update(JSON.stringify([pathspecs, output]));
|
|
10383
|
+
for (const file of parseGitStatusPaths(output))
|
|
10384
|
+
paths.add(file);
|
|
10385
|
+
}
|
|
10386
|
+
return {
|
|
10387
|
+
root: canonicalRoot,
|
|
10388
|
+
head: gitHead(repoPath) ?? "",
|
|
10389
|
+
statusFingerprint: statusHash.digest("hex"),
|
|
10390
|
+
statProofReliable: trustCtime !== "false" && checkStat !== "minimal",
|
|
10391
|
+
...freshnessDirtyPaths(canonicalRoot, [...paths], intent, db, standardExcludes),
|
|
10392
|
+
};
|
|
9831
10393
|
}
|
|
9832
10394
|
catch (error) {
|
|
9833
|
-
// An overflow is a degradation worth attributing — the fallback path is
|
|
9834
|
-
// bounded and can only answer "unknown" — so it is counted where the perf
|
|
9835
|
-
// harness and tests can see it, unlike a plain not-a-git-repo failure.
|
|
9836
10395
|
if (error.code === "ENOBUFS")
|
|
9837
|
-
freshnessStats.probeOverflows
|
|
10396
|
+
freshnessStats.probeOverflows++;
|
|
9838
10397
|
return null;
|
|
9839
10398
|
}
|
|
9840
|
-
const head = lines[0]?.trim();
|
|
9841
|
-
const root = lines[1]?.trim();
|
|
9842
|
-
if (!head || !root)
|
|
9843
|
-
return null;
|
|
9844
|
-
const dirty = [];
|
|
9845
|
-
for (const line of lines.slice(2)) {
|
|
9846
|
-
if (!line.trim())
|
|
9847
|
-
continue;
|
|
9848
|
-
// Porcelain: `XY <path>` (or `XY <orig> -> <new>` for renames).
|
|
9849
|
-
for (const part of line.slice(3).split(" -> ")) {
|
|
9850
|
-
const p = part.trim().replace(/^"|"$/g, "");
|
|
9851
|
-
if (p && isIndexablePath(p))
|
|
9852
|
-
dirty.push(p);
|
|
9853
|
-
}
|
|
9854
|
-
}
|
|
9855
|
-
dirty.sort(compareBytes);
|
|
9856
|
-
return { head, root, dirty };
|
|
9857
10399
|
}
|
|
9858
10400
|
/** True when two paths name the same directory (symlinks resolved). */
|
|
9859
10401
|
function isSameDir(a, b) {
|
|
@@ -9871,7 +10413,11 @@ function fileDriftedFromIndexState(db, repoPath, relPath) {
|
|
|
9871
10413
|
.get(relPath);
|
|
9872
10414
|
let st = null;
|
|
9873
10415
|
try {
|
|
9874
|
-
|
|
10416
|
+
if (!safeCoveragePath(repoPath, relPath))
|
|
10417
|
+
return Boolean(row);
|
|
10418
|
+
st = fs.lstatSync(path.join(repoPath, relPath));
|
|
10419
|
+
if (!st.isFile() || st.isSymbolicLink())
|
|
10420
|
+
return Boolean(row);
|
|
9875
10421
|
}
|
|
9876
10422
|
catch {
|
|
9877
10423
|
st = null;
|
|
@@ -9884,6 +10430,224 @@ function fileDriftedFromIndexState(db, repoPath, relPath) {
|
|
|
9884
10430
|
return true;
|
|
9885
10431
|
return row.mtimeMs !== st.mtimeMs || row.size !== st.size;
|
|
9886
10432
|
}
|
|
10433
|
+
const repairSourceAudit = new AsyncLocalStorage();
|
|
10434
|
+
const newRepairSourceAuditState = () => ({
|
|
10435
|
+
verified: new Map(),
|
|
10436
|
+
owned: new Set(),
|
|
10437
|
+
});
|
|
10438
|
+
const REPAIR_CHECKPOINT_PREFIX = "repair-source-v1:";
|
|
10439
|
+
const REPAIR_INITIALIZATION_KEY = "repair-source-initialization-v1";
|
|
10440
|
+
const CONTENT_PROOF_EPOCH_KEY = "contentProofEpochV1";
|
|
10441
|
+
/**
|
|
10442
|
+
* Audit record for a non-empty graph indexed before 0.13.1 introduced the
|
|
10443
|
+
* versioned content-proof epoch. Ordinary and scoped indexing refuse to mint
|
|
10444
|
+
* one for such a graph, so the only remedy is a clean index; `init` performs
|
|
10445
|
+
* it, and the lifecycle hook must fail loudly rather than report success.
|
|
10446
|
+
*/
|
|
10447
|
+
export const LEGACY_CONTENT_PROOF_RECORD = "content-proof epoch is missing or invalid; a one-time clean index is required";
|
|
10448
|
+
export const LEGACY_CONTENT_PROOF_REPAIR_STEP = "Run `knodin index --clean` once to establish a versioned content-proof epoch.";
|
|
10449
|
+
/** Whether an index result or thrown indexing error names the legacy-epoch condition. */
|
|
10450
|
+
export function needsLegacyEpochCleanIndex(value) {
|
|
10451
|
+
if (value instanceof Error)
|
|
10452
|
+
return value.message.includes("content-proof epoch");
|
|
10453
|
+
const verification = value?.verification;
|
|
10454
|
+
return Array.isArray(verification?.missing?.records)
|
|
10455
|
+
? verification.missing.records.includes(LEGACY_CONTENT_PROOF_RECORD)
|
|
10456
|
+
: false;
|
|
10457
|
+
}
|
|
10458
|
+
const CLEAN_INDEX_SOURCE_START_KEY = "cleanIndexSourceStartV1";
|
|
10459
|
+
/** Metadata provenance is a canonical UTC instant, not Date.parse's permissive grammar. */
|
|
10460
|
+
function validSourceEpoch(value, now = Date.now()) {
|
|
10461
|
+
if (!value || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value))
|
|
10462
|
+
return null;
|
|
10463
|
+
const epoch = Date.parse(value);
|
|
10464
|
+
return Number.isFinite(epoch) && epoch <= now && new Date(epoch).toISOString() === value
|
|
10465
|
+
? epoch
|
|
10466
|
+
: null;
|
|
10467
|
+
}
|
|
10468
|
+
/** Dirty content proof is bounded even for a very large checkout. */
|
|
10469
|
+
function dirtyContentFingerprint(repoPath, files) {
|
|
10470
|
+
if (files.length > 256)
|
|
10471
|
+
return null;
|
|
10472
|
+
let remaining = 1024 * 1024;
|
|
10473
|
+
const bytes = Buffer.alloc(64 * 1024);
|
|
10474
|
+
const hash = crypto.createHash("sha256");
|
|
10475
|
+
try {
|
|
10476
|
+
for (const file of files) {
|
|
10477
|
+
hash.update(JSON.stringify(file)).update("\0");
|
|
10478
|
+
let fd;
|
|
10479
|
+
const target = path.join(repoPath, file);
|
|
10480
|
+
let checked;
|
|
10481
|
+
try {
|
|
10482
|
+
checked = fs.lstatSync(target);
|
|
10483
|
+
if (!safeCoveragePath(repoPath, file) || !checked.isFile() || checked.isSymbolicLink())
|
|
10484
|
+
return null;
|
|
10485
|
+
fd = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
10486
|
+
}
|
|
10487
|
+
catch (error) {
|
|
10488
|
+
if (error.code === "ENOENT") {
|
|
10489
|
+
hash.update("deleted\0");
|
|
10490
|
+
continue;
|
|
10491
|
+
}
|
|
10492
|
+
throw error;
|
|
10493
|
+
}
|
|
10494
|
+
try {
|
|
10495
|
+
const stat = fs.fstatSync(fd);
|
|
10496
|
+
if (!stat.isFile() ||
|
|
10497
|
+
stat.size > remaining ||
|
|
10498
|
+
stat.dev !== checked.dev ||
|
|
10499
|
+
stat.ino !== checked.ino)
|
|
10500
|
+
return null;
|
|
10501
|
+
const fileHash = crypto.createHash("sha256");
|
|
10502
|
+
let length = 0;
|
|
10503
|
+
while (true) {
|
|
10504
|
+
const count = fs.readSync(fd, bytes, 0, Math.min(bytes.length, remaining + 1), null);
|
|
10505
|
+
if (!count)
|
|
10506
|
+
break;
|
|
10507
|
+
remaining -= count;
|
|
10508
|
+
if (remaining < 0)
|
|
10509
|
+
return null;
|
|
10510
|
+
length += count;
|
|
10511
|
+
fileHash.update(bytes.subarray(0, count));
|
|
10512
|
+
}
|
|
10513
|
+
hash.update(`${length}:${fileHash.digest("hex")}\0`);
|
|
10514
|
+
const final = fs.fstatSync(fd);
|
|
10515
|
+
if (!safeCoveragePath(repoPath, file))
|
|
10516
|
+
return null;
|
|
10517
|
+
const current = fs.lstatSync(target);
|
|
10518
|
+
if (current.dev !== stat.dev ||
|
|
10519
|
+
current.ino !== stat.ino ||
|
|
10520
|
+
current.size !== stat.size ||
|
|
10521
|
+
current.mtimeMs !== stat.mtimeMs ||
|
|
10522
|
+
current.ctimeMs !== stat.ctimeMs ||
|
|
10523
|
+
final.size !== stat.size ||
|
|
10524
|
+
final.mtimeMs !== stat.mtimeMs ||
|
|
10525
|
+
final.ctimeMs !== stat.ctimeMs ||
|
|
10526
|
+
length !== stat.size)
|
|
10527
|
+
return null;
|
|
10528
|
+
}
|
|
10529
|
+
finally {
|
|
10530
|
+
fs.closeSync(fd);
|
|
10531
|
+
}
|
|
10532
|
+
}
|
|
10533
|
+
return hash.digest("hex");
|
|
10534
|
+
}
|
|
10535
|
+
catch {
|
|
10536
|
+
return null;
|
|
10537
|
+
}
|
|
10538
|
+
}
|
|
10539
|
+
/** Fixed-size proofs only for completed repair files, never a source-inventory scan. */
|
|
10540
|
+
function repairCheckpointReader(db, repoPath) {
|
|
10541
|
+
const checkpoint = db.query("SELECT value FROM meta WHERE key=? AND length(value)<=512");
|
|
10542
|
+
const indexed = db.query("SELECT filePath,mtimeMs,size FROM index_state WHERE filePath=?");
|
|
10543
|
+
const bytes = Buffer.alloc(64 * 1024);
|
|
10544
|
+
return (file, suppliedRow) => {
|
|
10545
|
+
try {
|
|
10546
|
+
const raw = checkpoint.get(`${REPAIR_CHECKPOINT_PREFIX}${file}`)?.value;
|
|
10547
|
+
const row = suppliedRow ?? indexed.get(file);
|
|
10548
|
+
if (!raw || !row)
|
|
10549
|
+
return null;
|
|
10550
|
+
const proof = JSON.parse(raw);
|
|
10551
|
+
if (proof.version !== 1 ||
|
|
10552
|
+
typeof proof.sourceHash !== "string" ||
|
|
10553
|
+
!/^[a-f0-9]{64}$/.test(proof.sourceHash) ||
|
|
10554
|
+
proof.mtimeMs !== row.mtimeMs ||
|
|
10555
|
+
proof.size !== row.size)
|
|
10556
|
+
return null;
|
|
10557
|
+
const target = path.join(repoPath, file);
|
|
10558
|
+
if (!safeCoveragePath(repoPath, file))
|
|
10559
|
+
throw new Error("Repair source path is unsafe");
|
|
10560
|
+
const stat = fs.lstatSync(target);
|
|
10561
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
10562
|
+
throw new Error("Repair source is not a regular file");
|
|
10563
|
+
return {
|
|
10564
|
+
sourceHash: proof.sourceHash,
|
|
10565
|
+
matches: stat.mtimeMs === row.mtimeMs &&
|
|
10566
|
+
stat.size === row.size &&
|
|
10567
|
+
repairSourceFingerprint(repoPath, file, bytes) === proof.sourceHash,
|
|
10568
|
+
};
|
|
10569
|
+
}
|
|
10570
|
+
catch {
|
|
10571
|
+
return null;
|
|
10572
|
+
}
|
|
10573
|
+
};
|
|
10574
|
+
}
|
|
10575
|
+
/** Repair-only content fence; memory is bounded independently of source size. */
|
|
10576
|
+
function repairSourceFingerprint(repoPath, file, bytes) {
|
|
10577
|
+
const target = path.join(repoPath, file);
|
|
10578
|
+
if (!safeCoveragePath(repoPath, file))
|
|
10579
|
+
throw new Error("Repair source path is unsafe");
|
|
10580
|
+
let checked;
|
|
10581
|
+
let fd;
|
|
10582
|
+
try {
|
|
10583
|
+
checked = fs.lstatSync(target);
|
|
10584
|
+
if (!checked.isFile() || checked.isSymbolicLink())
|
|
10585
|
+
throw new Error("Repair source is not a regular file");
|
|
10586
|
+
fd = fs.openSync(target, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
10587
|
+
}
|
|
10588
|
+
catch (error) {
|
|
10589
|
+
if (error.code === "ENOENT")
|
|
10590
|
+
return null;
|
|
10591
|
+
throw error;
|
|
10592
|
+
}
|
|
10593
|
+
try {
|
|
10594
|
+
const opened = fs.fstatSync(fd);
|
|
10595
|
+
if (!opened.isFile() || opened.dev !== checked.dev || opened.ino !== checked.ino)
|
|
10596
|
+
throw new Error("Repair source changed during open");
|
|
10597
|
+
const hash = crypto.createHash("sha256");
|
|
10598
|
+
let length = 0;
|
|
10599
|
+
while (true) {
|
|
10600
|
+
const count = fs.readSync(fd, bytes, 0, bytes.length, null);
|
|
10601
|
+
if (!count)
|
|
10602
|
+
break;
|
|
10603
|
+
length += count;
|
|
10604
|
+
hash.update(bytes.subarray(0, count));
|
|
10605
|
+
}
|
|
10606
|
+
if (!safeCoveragePath(repoPath, file))
|
|
10607
|
+
throw new Error("Repair source path changed during read");
|
|
10608
|
+
const final = fs.fstatSync(fd);
|
|
10609
|
+
const current = fs.lstatSync(target);
|
|
10610
|
+
if (!current.isFile() ||
|
|
10611
|
+
current.isSymbolicLink() ||
|
|
10612
|
+
current.dev !== opened.dev ||
|
|
10613
|
+
current.ino !== opened.ino ||
|
|
10614
|
+
current.size !== opened.size ||
|
|
10615
|
+
current.mtimeMs !== opened.mtimeMs ||
|
|
10616
|
+
current.ctimeMs !== opened.ctimeMs ||
|
|
10617
|
+
final.size !== opened.size ||
|
|
10618
|
+
final.mtimeMs !== opened.mtimeMs ||
|
|
10619
|
+
final.ctimeMs !== opened.ctimeMs ||
|
|
10620
|
+
length !== opened.size)
|
|
10621
|
+
throw new Error("Repair source changed during read");
|
|
10622
|
+
return hash.digest("hex");
|
|
10623
|
+
}
|
|
10624
|
+
finally {
|
|
10625
|
+
fs.closeSync(fd);
|
|
10626
|
+
}
|
|
10627
|
+
}
|
|
10628
|
+
/** Existing indexed rows only; detects equal-stat writes after the proved start. */
|
|
10629
|
+
function sourceSnapshotMatches(db, repoPath, epoch) {
|
|
10630
|
+
const readCheckpoint = repairCheckpointReader(db, repoPath);
|
|
10631
|
+
for (const row of db
|
|
10632
|
+
.query("SELECT filePath,mtimeMs,size FROM index_state")
|
|
10633
|
+
.all()) {
|
|
10634
|
+
try {
|
|
10635
|
+
if (!safeCoveragePath(repoPath, row.filePath))
|
|
10636
|
+
return false;
|
|
10637
|
+
const stat = fs.lstatSync(path.join(repoPath, row.filePath));
|
|
10638
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
10639
|
+
return false;
|
|
10640
|
+
if (stat.mtimeMs !== row.mtimeMs ||
|
|
10641
|
+
stat.size !== row.size ||
|
|
10642
|
+
(stat.ctimeMs >= epoch - 1 && !readCheckpoint(row.filePath, row)?.matches))
|
|
10643
|
+
return false;
|
|
10644
|
+
}
|
|
10645
|
+
catch {
|
|
10646
|
+
return false;
|
|
10647
|
+
}
|
|
10648
|
+
}
|
|
10649
|
+
return true;
|
|
10650
|
+
}
|
|
9887
10651
|
/**
|
|
9888
10652
|
* Record the working-tree state the index now reflects. Called at the end of a
|
|
9889
10653
|
* full index and of every reconcile so the next probe has a baseline to
|
|
@@ -9891,15 +10655,19 @@ function fileDriftedFromIndexState(db, repoPath, relPath) {
|
|
|
9891
10655
|
* bump — and self-heals on indexes written before this key existed.
|
|
9892
10656
|
*/
|
|
9893
10657
|
function coverageDirtyFingerprint(intent, dirty) {
|
|
9894
|
-
|
|
10658
|
+
// The prefix invalidates legacy newline baselines (and ambiguous JSON-looking
|
|
10659
|
+
// filenames) once without trying to decode those lossy historical snapshots.
|
|
10660
|
+
return `paths-v2:${intent?.mode ?? "full"}:${JSON.stringify(dirty)}`;
|
|
9895
10661
|
}
|
|
9896
10662
|
function recordFreshnessBaseline(repoPath, db) {
|
|
9897
10663
|
const intent = requireIndexCoverage(coverageForDatabase(db));
|
|
9898
|
-
const git = gitWorkTreeProbe(repoPath, intent);
|
|
10664
|
+
const git = gitWorkTreeProbe(repoPath, intent, db);
|
|
9899
10665
|
const dirty = git && isSameDir(git.root, repoPath)
|
|
9900
10666
|
? git.dirty.filter((file) => coverageContains(intent, file))
|
|
9901
10667
|
: null;
|
|
9902
10668
|
setMeta(db, "lastProbeDirty", dirty === null ? "" : coverageDirtyFingerprint(intent, dirty));
|
|
10669
|
+
setMeta(db, "lastProbePolicy", git?.policyFingerprint ?? "");
|
|
10670
|
+
setMeta(db, "lastProbeContent", dirty === null ? "" : (dirtyContentFingerprint(repoPath, dirty) ?? ""));
|
|
9903
10671
|
setMeta(db, "lastProbeCoverage", indexCoverageSummary(coverageForDatabase(db)).digest ?? "");
|
|
9904
10672
|
}
|
|
9905
10673
|
/**
|
|
@@ -9929,20 +10697,31 @@ function probeDrift(repoPath, db, gitProbe) {
|
|
|
9929
10697
|
(coverage.origin === "explicit" &&
|
|
9930
10698
|
getMeta(db, "lastProbeCoverage") !== indexCoverageSummary(coverage).digest))
|
|
9931
10699
|
return { drifted: true, verifiable: true };
|
|
9932
|
-
const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath, intent) : gitProbe;
|
|
9933
|
-
|
|
10700
|
+
const git = gitProbe === undefined ? gitWorkTreeProbe(repoPath, intent, db) : gitProbe;
|
|
10701
|
+
// An indexed-only stat fallback cannot prove that failed Git/ignore-policy
|
|
10702
|
+
// inspection did not hide newly eligible source. Non-Git worktrees
|
|
10703
|
+
// retain the existing bounded fallback.
|
|
10704
|
+
if (!git && hasGitCheckout(repoPath))
|
|
10705
|
+
return { drifted: false, verifiable: false };
|
|
10706
|
+
if (hasGitCheckout(repoPath) && validSourceEpoch(getMeta(db, CONTENT_PROOF_EPOCH_KEY)) === null)
|
|
9934
10707
|
return { drifted: false, verifiable: false };
|
|
9935
10708
|
if (git && isSameDir(git.root, repoPath)) {
|
|
9936
10709
|
if ((getMeta(db, "lastIndexedHead") ?? "") !== git.head) {
|
|
9937
10710
|
return { drifted: true, verifiable: true };
|
|
9938
10711
|
}
|
|
9939
10712
|
const dirty = git.dirty.filter((file) => coverageContains(intent, file));
|
|
9940
|
-
if (getMeta(db, "lastProbeDirty") !== coverageDirtyFingerprint(intent, dirty)
|
|
10713
|
+
if (getMeta(db, "lastProbeDirty") !== coverageDirtyFingerprint(intent, dirty) ||
|
|
10714
|
+
(getMeta(db, "lastProbePolicy") ?? "") !== git.policyFingerprint) {
|
|
9941
10715
|
return { drifted: true, verifiable: true };
|
|
9942
10716
|
}
|
|
10717
|
+
if (!git.statProofReliable)
|
|
10718
|
+
return { drifted: false, verifiable: false };
|
|
10719
|
+
const content = dirtyContentFingerprint(repoPath, dirty);
|
|
10720
|
+
if (content === null || !getMeta(db, "lastProbeContent"))
|
|
10721
|
+
return { drifted: false, verifiable: false };
|
|
10722
|
+
if (content !== getMeta(db, "lastProbeContent"))
|
|
10723
|
+
return { drifted: true, verifiable: true };
|
|
9943
10724
|
for (const rel of dirty) {
|
|
9944
|
-
if (!isIndexablePath(rel))
|
|
9945
|
-
continue;
|
|
9946
10725
|
if (fileDriftedFromIndexState(db, repoPath, rel))
|
|
9947
10726
|
return { drifted: true, verifiable: true };
|
|
9948
10727
|
}
|
|
@@ -10059,7 +10838,31 @@ async function indexRepo(repoPath, db, progress, skipEmbeddings = getMeta(db, "s
|
|
|
10059
10838
|
* partially-populated intermediate state and the live graph must never be
|
|
10060
10839
|
* observable in that condition.
|
|
10061
10840
|
*/
|
|
10062
|
-
resume = false) {
|
|
10841
|
+
resume = false, explicitClean = false) {
|
|
10842
|
+
if (!explicitClean &&
|
|
10843
|
+
hasGitCheckout(repoPath) &&
|
|
10844
|
+
validSourceEpoch(getMeta(db, CONTENT_PROOF_EPOCH_KEY)) === null &&
|
|
10845
|
+
(db.query("SELECT COUNT(*) count FROM index_state").get()?.count ?? 0) >
|
|
10846
|
+
0)
|
|
10847
|
+
throw new Error("Existing graph has no valid content-proof epoch; a one-time knodin index --clean is required.");
|
|
10848
|
+
// Completion/reconciliation timestamps are not content evidence. Keep the
|
|
10849
|
+
// original start across a resumed clean build so edits during it remain
|
|
10850
|
+
// eligible for the repair-only ctime audit.
|
|
10851
|
+
const contentStart = resume
|
|
10852
|
+
? getMeta(db, CLEAN_INDEX_SOURCE_START_KEY)
|
|
10853
|
+
: new Date().toISOString();
|
|
10854
|
+
const contentEpoch = validSourceEpoch(contentStart);
|
|
10855
|
+
if (!contentStart || contentEpoch === null)
|
|
10856
|
+
throw new Error("Clean index source epoch is unavailable; restart with knodin index --clean.");
|
|
10857
|
+
const initialGit = gitWorkTreeProbe(repoPath, requireIndexCoverage(coverageForDatabase(db)), db);
|
|
10858
|
+
const originalHead = resume
|
|
10859
|
+
? (getMeta(db, CLEAN_INDEX_HEAD_META_KEY) ?? "")
|
|
10860
|
+
: (gitHead(repoPath) ?? "");
|
|
10861
|
+
if (hasGitCheckout(repoPath) && (!initialGit || initialGit.head !== originalHead))
|
|
10862
|
+
throw new Error("Repository HEAD changed during clean index; restart with knodin index --clean.");
|
|
10863
|
+
setMeta(db, CLEAN_INDEX_SOURCE_START_KEY, contentStart);
|
|
10864
|
+
if (!resume)
|
|
10865
|
+
db.run("DELETE FROM meta WHERE key=?", [CONTENT_PROOF_EPOCH_KEY]);
|
|
10063
10866
|
setMeta(db, "semanticDeferred", skipEmbeddings ? "1" : "0");
|
|
10064
10867
|
progress?.("collecting-files", 0, "Discovering indexable files");
|
|
10065
10868
|
const collected = measurePerfPhaseSync("file_collection", () => collectRepoFilesWithCoverage(repoPath, db));
|
|
@@ -10110,6 +10913,11 @@ resume = false) {
|
|
|
10110
10913
|
db.run("DELETE FROM dependencies;");
|
|
10111
10914
|
db.run("DELETE FROM mcp_tools;");
|
|
10112
10915
|
db.run("DELETE FROM index_state;");
|
|
10916
|
+
db.run("DELETE FROM meta WHERE key>=? AND key<?", [
|
|
10917
|
+
REPAIR_CHECKPOINT_PREFIX,
|
|
10918
|
+
"repair-source-v1;",
|
|
10919
|
+
]);
|
|
10920
|
+
db.run("DELETE FROM meta WHERE key=?", [REPAIR_INITIALIZATION_KEY]);
|
|
10113
10921
|
db.run("DELETE FROM apex_consumer_files;");
|
|
10114
10922
|
db.run("DELETE FROM apex_class_uses;");
|
|
10115
10923
|
setMeta(db, "apexConsumerFactsRevision", crypto.randomUUID());
|
|
@@ -10127,9 +10935,24 @@ resume = false) {
|
|
|
10127
10935
|
let resumedFiles = 0;
|
|
10128
10936
|
let resumedBytes = 0;
|
|
10129
10937
|
if (resume) {
|
|
10938
|
+
const eligible = new Set(files);
|
|
10939
|
+
for (const row of db
|
|
10940
|
+
.query("SELECT filePath,mtimeMs,size FROM index_state")
|
|
10941
|
+
.all()) {
|
|
10942
|
+
if (!eligible.has(row.filePath)) {
|
|
10943
|
+
deleteSymbolsForFile(db, row.filePath);
|
|
10944
|
+
db.run('DELETE FROM "references" WHERE callerFile=?', [row.filePath]);
|
|
10945
|
+
db.run("DELETE FROM dependencies WHERE fromFile=?", [row.filePath]);
|
|
10946
|
+
db.run("DELETE FROM mcp_tools WHERE filePath=?", [row.filePath]);
|
|
10947
|
+
removeIndexState(db, row.filePath);
|
|
10948
|
+
}
|
|
10949
|
+
}
|
|
10950
|
+
const readCheckpoint = repairCheckpointReader(db, repoPath);
|
|
10130
10951
|
const pending = [];
|
|
10131
10952
|
for (const file of files) {
|
|
10132
|
-
|
|
10953
|
+
const stat = fs.statSync(path.join(repoPath, file));
|
|
10954
|
+
if (fileDriftedFromIndexState(db, repoPath, file) ||
|
|
10955
|
+
(stat.ctimeMs >= contentEpoch - 1 && !readCheckpoint(file)?.matches)) {
|
|
10133
10956
|
pending.push(file);
|
|
10134
10957
|
continue;
|
|
10135
10958
|
}
|
|
@@ -10155,23 +10978,82 @@ resume = false) {
|
|
|
10155
10978
|
// through per-file SAVEPOINTs inside `writeFileIndexResultBatch`).
|
|
10156
10979
|
let lastYieldAt = Date.now();
|
|
10157
10980
|
const pendingWrites = [];
|
|
10981
|
+
const cleanProofs = new Map();
|
|
10982
|
+
let batchWritesFailed = false;
|
|
10158
10983
|
const flushPendingWrites = () => {
|
|
10159
10984
|
if (pendingWrites.length === 0)
|
|
10160
10985
|
return;
|
|
10161
10986
|
const failures = writeFileIndexResultBatch(db, pendingWrites);
|
|
10987
|
+
if (failures.length > 0)
|
|
10988
|
+
batchWritesFailed = true;
|
|
10162
10989
|
for (const failure of failures) {
|
|
10163
10990
|
console.error(`Error indexing file ${failure.relativePath}:`, failure.error);
|
|
10164
10991
|
tallyOne(unparsedByExtension, extensionBucket(failure.relativePath));
|
|
10992
|
+
cleanProofs.delete(failure.relativePath);
|
|
10993
|
+
db.run("DELETE FROM meta WHERE key=?", [
|
|
10994
|
+
`${REPAIR_CHECKPOINT_PREFIX}${failure.relativePath}`,
|
|
10995
|
+
]);
|
|
10165
10996
|
}
|
|
10166
10997
|
pendingWrites.length = 0;
|
|
10998
|
+
// Generic batched writes invalidate old proofs. Restore the proofs
|
|
10999
|
+
// captured around this build's actual parse, including on interruption.
|
|
11000
|
+
for (const [file, proof] of cleanProofs)
|
|
11001
|
+
setMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${file}`, proof);
|
|
11002
|
+
cleanProofs.clear();
|
|
10167
11003
|
};
|
|
10168
11004
|
// Progress is reported against a single counter so the pool and the
|
|
10169
11005
|
// sequential path can interleave without the count going backwards.
|
|
10170
11006
|
let processed = resumedFiles;
|
|
11007
|
+
const parseStats = new Map();
|
|
11008
|
+
const proofBytes = Buffer.alloc(64 * 1024);
|
|
11009
|
+
// Tracks completed source consumption (including unparsed/skipped outcomes).
|
|
11010
|
+
// Failed writes remain excluded or have their proofs removed during flush.
|
|
11011
|
+
const extractedFiles = new Set();
|
|
11012
|
+
const beforeFile = (file) => {
|
|
11013
|
+
const stat = fs.statSync(path.join(repoPath, file));
|
|
11014
|
+
// Worker extraction bypasses indexFile. Bind its original queued source
|
|
11015
|
+
// too, including pre-epoch files, not a hash observed only after parsing.
|
|
11016
|
+
parseStats.set(file, {
|
|
11017
|
+
ctimeMs: stat.ctimeMs,
|
|
11018
|
+
mtimeMs: stat.mtimeMs,
|
|
11019
|
+
size: stat.size,
|
|
11020
|
+
sourceHash: repairSourceFingerprint(repoPath, file, proofBytes),
|
|
11021
|
+
});
|
|
11022
|
+
};
|
|
10171
11023
|
const afterFile = async (file) => {
|
|
11024
|
+
const before = parseStats.get(file);
|
|
11025
|
+
const after = fs.statSync(path.join(repoPath, file));
|
|
11026
|
+
if ((!before && after.ctimeMs >= contentEpoch - 1) ||
|
|
11027
|
+
(before &&
|
|
11028
|
+
(before.ctimeMs !== after.ctimeMs ||
|
|
11029
|
+
before.mtimeMs !== after.mtimeMs ||
|
|
11030
|
+
before.size !== after.size ||
|
|
11031
|
+
(before.sourceHash !== null &&
|
|
11032
|
+
repairSourceFingerprint(repoPath, file, proofBytes) !== before.sourceHash))))
|
|
11033
|
+
throw new Error(`Source changed during clean index: ${file}; restart with knodin index --clean.`);
|
|
11034
|
+
completedBytes += recordIndexState(db, repoPath, file);
|
|
11035
|
+
// A later generic batch write invalidates this extraction's checkpoint.
|
|
11036
|
+
// Retain it for restoration only after that file's successful write.
|
|
11037
|
+
if (pendingWrites.length > 0) {
|
|
11038
|
+
const parsedProof = getMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${file}`);
|
|
11039
|
+
if (parsedProof)
|
|
11040
|
+
cleanProofs.set(file, parsedProof);
|
|
11041
|
+
}
|
|
11042
|
+
if (extractedFiles.has(file) && before && before.sourceHash !== null) {
|
|
11043
|
+
const proof = JSON.stringify({
|
|
11044
|
+
version: 1,
|
|
11045
|
+
sourceHash: before.sourceHash,
|
|
11046
|
+
mtimeMs: after.mtimeMs,
|
|
11047
|
+
size: after.size,
|
|
11048
|
+
});
|
|
11049
|
+
setMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${file}`, proof);
|
|
11050
|
+
if (pendingWrites.length > 0)
|
|
11051
|
+
cleanProofs.set(file, proof);
|
|
11052
|
+
}
|
|
11053
|
+
parseStats.delete(file);
|
|
11054
|
+
extractedFiles.delete(file);
|
|
10172
11055
|
if (pendingWrites.length >= INDEX_WRITE_BATCH_SIZE)
|
|
10173
11056
|
flushPendingWrites();
|
|
10174
|
-
completedBytes += recordIndexState(db, repoPath, file);
|
|
10175
11057
|
processed++;
|
|
10176
11058
|
// Persisted on the same cadence as the write batches rather than only at
|
|
10177
11059
|
// the end, so an interrupted index keeps what it learned. Cheap: one meta
|
|
@@ -10189,9 +11071,12 @@ resume = false) {
|
|
|
10189
11071
|
}
|
|
10190
11072
|
};
|
|
10191
11073
|
const indexOneInline = async (file) => {
|
|
10192
|
-
|
|
11074
|
+
beforeFile(file);
|
|
11075
|
+
const extracted = await indexFile(path.join(repoPath, file), file, repoPath, db, unparsedByExtension, (result) => {
|
|
10193
11076
|
pendingWrites.push(result);
|
|
10194
11077
|
});
|
|
11078
|
+
if (extracted)
|
|
11079
|
+
extractedFiles.add(file);
|
|
10195
11080
|
};
|
|
10196
11081
|
// Opt-in for now. `createParsePool` returns null whenever the machine says
|
|
10197
11082
|
// not to spawn, so an unsuitable host silently keeps today's behavior
|
|
@@ -10212,17 +11097,22 @@ resume = false) {
|
|
|
10212
11097
|
else
|
|
10213
11098
|
inline.push(file);
|
|
10214
11099
|
}
|
|
11100
|
+
for (const task of eligible)
|
|
11101
|
+
beforeFile(task.relativePath);
|
|
10215
11102
|
await pool.run(eligible, async (task, outcome) => {
|
|
10216
11103
|
switch (outcome.kind) {
|
|
10217
11104
|
case "result":
|
|
11105
|
+
extractedFiles.add(task.relativePath);
|
|
10218
11106
|
if (outcome.result.apexClassUseIncompleteReason)
|
|
10219
11107
|
tallyOne(unparsedByExtension, extensionBucket(task.relativePath));
|
|
10220
11108
|
pendingWrites.push(outcome.result);
|
|
10221
11109
|
break;
|
|
10222
11110
|
case "unparsed":
|
|
11111
|
+
extractedFiles.add(task.relativePath);
|
|
10223
11112
|
tallyOne(unparsedByExtension, extensionBucket(task.relativePath));
|
|
10224
11113
|
break;
|
|
10225
11114
|
case "skipped":
|
|
11115
|
+
extractedFiles.add(task.relativePath);
|
|
10226
11116
|
break;
|
|
10227
11117
|
default:
|
|
10228
11118
|
// The pool could not finish this file (worker crash, or it
|
|
@@ -10249,6 +11139,8 @@ resume = false) {
|
|
|
10249
11139
|
await pool?.close();
|
|
10250
11140
|
}
|
|
10251
11141
|
flushPendingWrites();
|
|
11142
|
+
if (batchWritesFailed)
|
|
11143
|
+
throw new Error("Index batch writes failed; content-completion proof not published. Resolve the write failure and retry indexing.");
|
|
10252
11144
|
progress?.("finalizing", 0, "Persisting symbol identities and semantic index");
|
|
10253
11145
|
await refreshFlowsForApexFiles(db, repoPath, files);
|
|
10254
11146
|
persistSymbolIdentities(db, repoPath);
|
|
@@ -10260,10 +11152,30 @@ resume = false) {
|
|
|
10260
11152
|
if (!skipEmbeddings)
|
|
10261
11153
|
await indexEmbeddings(db, repoPath, progress);
|
|
10262
11154
|
// Remember the HEAD we indexed at so a later cold start can diff forward.
|
|
10263
|
-
|
|
11155
|
+
const finalGit = gitWorkTreeProbe(repoPath, requireIndexCoverage(coverageForDatabase(db)), db);
|
|
11156
|
+
if (hasGitCheckout(repoPath) &&
|
|
11157
|
+
(!initialGit ||
|
|
11158
|
+
!finalGit ||
|
|
11159
|
+
finalGit.head !== originalHead ||
|
|
11160
|
+
initialGit.policyFingerprint !== finalGit.policyFingerprint ||
|
|
11161
|
+
initialGit.statusFingerprint !== finalGit.statusFingerprint))
|
|
11162
|
+
throw new Error("Repository changed during clean index; restart with knodin index --clean.");
|
|
11163
|
+
const readCheckpoint = repairCheckpointReader(db, repoPath);
|
|
11164
|
+
for (const row of db
|
|
11165
|
+
.query("SELECT filePath,mtimeMs,size FROM index_state")
|
|
11166
|
+
.all()) {
|
|
11167
|
+
const stat = fs.statSync(path.join(repoPath, row.filePath));
|
|
11168
|
+
if (stat.mtimeMs !== row.mtimeMs ||
|
|
11169
|
+
stat.size !== row.size ||
|
|
11170
|
+
(stat.ctimeMs >= contentEpoch - 1 && !readCheckpoint(row.filePath, row)?.matches))
|
|
11171
|
+
throw new Error(`Source changed during clean index: ${row.filePath}; restart with knodin index --clean.`);
|
|
11172
|
+
}
|
|
11173
|
+
setMeta(db, "lastIndexedHead", originalHead);
|
|
10264
11174
|
setMeta(db, "knodinVersion", KNODIN_VERSION);
|
|
10265
11175
|
setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
|
|
10266
11176
|
setMeta(db, "mcpBackfillVersion", "17");
|
|
11177
|
+
setMeta(db, CONTENT_PROOF_EPOCH_KEY, contentStart);
|
|
11178
|
+
db.run("DELETE FROM meta WHERE key=?", [CLEAN_INDEX_SOURCE_START_KEY]);
|
|
10267
11179
|
persistUnparsedTally(db, unparsedByExtension);
|
|
10268
11180
|
// Only now is the tally a measurement rather than a lower bound. Clearing the
|
|
10269
11181
|
// marker LAST, after the final write, means any failure above leaves it set
|
|
@@ -10893,6 +11805,8 @@ export async function getOrInitDb(repoPath, options = {}) {
|
|
|
10893
11805
|
else if (!options.skipContentReconciliation) {
|
|
10894
11806
|
// Existing on-disk index: reconcile any offline drift (git pull /
|
|
10895
11807
|
// branch switch / rebase / external edit) before serving queries.
|
|
11808
|
+
// Always retain canonical discovery and relationship/semantic maintenance.
|
|
11809
|
+
// Reconciliation skips extraction only per file with retained parsed proof.
|
|
10896
11810
|
const reindexed = await reconcileIndex(normalizedPath, db, options.onProgress);
|
|
10897
11811
|
const missingIdentities = db
|
|
10898
11812
|
.query("SELECT COUNT(*) count FROM symbols WHERE identity IS NULL OR identity = ''")
|
|
@@ -13346,6 +14260,10 @@ function withStaleness(engine, claimRepository, openDb, openPolicy) {
|
|
|
13346
14260
|
claimRepository(candidate.repo);
|
|
13347
14261
|
return engine.promoteCandidate(candidate);
|
|
13348
14262
|
},
|
|
14263
|
+
finalizePromotedStorage(repoPath) {
|
|
14264
|
+
claimRepository(repoPath);
|
|
14265
|
+
engine.finalizePromotedStorage?.(repoPath);
|
|
14266
|
+
},
|
|
13349
14267
|
discardCandidate(candidate) {
|
|
13350
14268
|
claimRepository(candidate.repo);
|
|
13351
14269
|
return engine.discardCandidate(candidate);
|
|
@@ -13527,12 +14445,149 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13527
14445
|
for (const suffix of ["-wal", "-shm"])
|
|
13528
14446
|
fs.rmSync(`${candidate.databasePath}${suffix}`, { force: true });
|
|
13529
14447
|
};
|
|
13530
|
-
const candidateDigest = (candidate) =>
|
|
14448
|
+
const candidateDigest = (candidate) => {
|
|
14449
|
+
const bound = bindCandidateDatabase(candidate.repo, candidate);
|
|
14450
|
+
try {
|
|
14451
|
+
const digest = crypto
|
|
14452
|
+
.createHash("sha256")
|
|
14453
|
+
.update(fs.readFileSync(bound.descriptor))
|
|
14454
|
+
.digest("hex");
|
|
14455
|
+
bound.verify();
|
|
14456
|
+
return digest;
|
|
14457
|
+
}
|
|
14458
|
+
finally {
|
|
14459
|
+
bound.close();
|
|
14460
|
+
}
|
|
14461
|
+
};
|
|
14462
|
+
const storageInitializationPending = (repository) => {
|
|
14463
|
+
if (isMirror(repository))
|
|
14464
|
+
return false;
|
|
14465
|
+
const activePath = resolveDbPath(repository);
|
|
14466
|
+
if (!fs.existsSync(activePath))
|
|
14467
|
+
return true;
|
|
14468
|
+
const active = dbInstances.get(repository);
|
|
14469
|
+
const db = active ?? new Database(activePath, { readonly: true });
|
|
14470
|
+
try {
|
|
14471
|
+
const value = db
|
|
14472
|
+
.query("SELECT value FROM meta WHERE key = ?")
|
|
14473
|
+
.get("storagePolicyInitializationPendingV1")?.value;
|
|
14474
|
+
if (value !== undefined && value !== "1")
|
|
14475
|
+
throw new Error("storage-policy: invalid pending initialization marker");
|
|
14476
|
+
return value === "1";
|
|
14477
|
+
}
|
|
14478
|
+
finally {
|
|
14479
|
+
if (!active)
|
|
14480
|
+
db.close();
|
|
14481
|
+
}
|
|
14482
|
+
};
|
|
14483
|
+
const completeStorageMaintenance = (repository) => {
|
|
14484
|
+
if (process.env.VITEST || process.env.NODE_ENV === "test")
|
|
14485
|
+
return;
|
|
14486
|
+
if (isMirror(repository))
|
|
14487
|
+
return;
|
|
14488
|
+
// This runs only after a successful write and lease release. Storage
|
|
14489
|
+
// maintenance must never undo a committed graph or hide cleanup failures.
|
|
14490
|
+
try {
|
|
14491
|
+
assertStoragePolicyWritable(repository);
|
|
14492
|
+
const lease = acquireRepairLease(repository, "storage-policy-initialization");
|
|
14493
|
+
try {
|
|
14494
|
+
const active = dbInstances.get(repository);
|
|
14495
|
+
const policyDb = active ?? new Database(resolveDbPath(repository));
|
|
14496
|
+
try {
|
|
14497
|
+
if (getMeta(policyDb, "storagePolicyInitializationPendingV1") === "1") {
|
|
14498
|
+
const databasePath = resolveDbPath(repository);
|
|
14499
|
+
const cacheKey = databaseCacheKey(repository);
|
|
14500
|
+
// The repair lease does not fence every graph writer. Validate
|
|
14501
|
+
// and perform this metadata-only change under a SQLite writer lock.
|
|
14502
|
+
policyDb.run("BEGIN IMMEDIATE");
|
|
14503
|
+
let committed = false;
|
|
14504
|
+
try {
|
|
14505
|
+
const identity = fs.lstatSync(databasePath);
|
|
14506
|
+
const version = policyDb
|
|
14507
|
+
.query("PRAGMA data_version")
|
|
14508
|
+
.get()?.data_version;
|
|
14509
|
+
const beforeFingerprint = databaseFileFingerprint(databasePath, policyDb);
|
|
14510
|
+
const schema = policyDb.query("PRAGMA user_version").get()
|
|
14511
|
+
?.user_version ?? 0;
|
|
14512
|
+
const persisted = readPersistedStatusAudit(repository, databasePath, beforeFingerprint, schema);
|
|
14513
|
+
const cached = statusCache.get(cacheKey);
|
|
14514
|
+
initializeStoragePolicy(repository);
|
|
14515
|
+
policyDb.run("DELETE FROM meta WHERE key = ? AND value = ?", [
|
|
14516
|
+
"storagePolicyInitializationPendingV1",
|
|
14517
|
+
"1",
|
|
14518
|
+
]);
|
|
14519
|
+
policyDb.run("COMMIT");
|
|
14520
|
+
committed = true;
|
|
14521
|
+
// Normalize our new WAL frame before binding the audit, so
|
|
14522
|
+
// an otherwise clean close/reopen does not discard it again.
|
|
14523
|
+
const checkpoint = policyDb
|
|
14524
|
+
.query("PRAGMA wal_checkpoint(TRUNCATE)")
|
|
14525
|
+
.get();
|
|
14526
|
+
const afterFingerprint = databaseFileFingerprint(databasePath, policyDb);
|
|
14527
|
+
const afterVersion = policyDb
|
|
14528
|
+
.query("PRAGMA data_version")
|
|
14529
|
+
.get()?.data_version;
|
|
14530
|
+
const afterIdentity = fs.lstatSync(databasePath);
|
|
14531
|
+
// A foreign commit or promotion after our transaction invalidates
|
|
14532
|
+
// carry-forward. Preserve original audit timestamps/source proofs.
|
|
14533
|
+
if (checkpoint?.busy === 0 &&
|
|
14534
|
+
version !== undefined &&
|
|
14535
|
+
version === afterVersion &&
|
|
14536
|
+
identity.dev === afterIdentity.dev &&
|
|
14537
|
+
identity.ino === afterIdentity.ino &&
|
|
14538
|
+
!afterIdentity.isSymbolicLink()) {
|
|
14539
|
+
if (persisted)
|
|
14540
|
+
writePersistedStatusAudit(repository, databasePath, afterFingerprint, persisted.result);
|
|
14541
|
+
if (cached?.generation === indexGeneration &&
|
|
14542
|
+
cached.databaseFingerprint === beforeFingerprint &&
|
|
14543
|
+
cached.result.status === "healthy" &&
|
|
14544
|
+
cached.result.verification.mode === "deep-audit")
|
|
14545
|
+
statusCache.set(cacheKey, { ...cached, databaseFingerprint: afterFingerprint });
|
|
14546
|
+
}
|
|
14547
|
+
}
|
|
14548
|
+
finally {
|
|
14549
|
+
if (!committed) {
|
|
14550
|
+
try {
|
|
14551
|
+
policyDb.run("ROLLBACK");
|
|
14552
|
+
}
|
|
14553
|
+
catch {
|
|
14554
|
+
/* Preserve the original failure. */
|
|
14555
|
+
}
|
|
14556
|
+
}
|
|
14557
|
+
}
|
|
14558
|
+
}
|
|
14559
|
+
}
|
|
14560
|
+
finally {
|
|
14561
|
+
if (!active)
|
|
14562
|
+
policyDb.close();
|
|
14563
|
+
}
|
|
14564
|
+
}
|
|
14565
|
+
finally {
|
|
14566
|
+
lease.release();
|
|
14567
|
+
}
|
|
14568
|
+
const maintenance = postOperationStorageMaintenance(repository);
|
|
14569
|
+
if (maintenance.warnings.length)
|
|
14570
|
+
process.stderr.write(`knodin storage: ${maintenance.warnings.join("; ")}\n`);
|
|
14571
|
+
if (maintenance.policy?.enabled && !maintenance.policy.dryRun) {
|
|
14572
|
+
let reported = 0;
|
|
14573
|
+
sweepAbandonedCandidates(repository, {
|
|
14574
|
+
onDiagnostic: (warning) => {
|
|
14575
|
+
if (reported++ < 8)
|
|
14576
|
+
process.stderr.write(`knodin storage: ${warning.reason.slice(0, 512)}\n`);
|
|
14577
|
+
},
|
|
14578
|
+
});
|
|
14579
|
+
}
|
|
14580
|
+
}
|
|
14581
|
+
catch (error) {
|
|
14582
|
+
process.stderr.write(`knodin storage: maintenance failed after graph commit: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
14583
|
+
}
|
|
14584
|
+
};
|
|
13531
14585
|
async function repairThroughCandidate(resolved, repairKey, options) {
|
|
13532
14586
|
let outerLease;
|
|
13533
14587
|
let candidate;
|
|
13534
14588
|
let candidateEngine;
|
|
13535
14589
|
let before;
|
|
14590
|
+
let storagePromoted = false;
|
|
13536
14591
|
const activeOperation = {
|
|
13537
14592
|
promise: undefined,
|
|
13538
14593
|
subscribers: new Set(options?.onProgress ? [options.onProgress] : []),
|
|
@@ -13563,10 +14618,21 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13563
14618
|
const operation = Promise.resolve()
|
|
13564
14619
|
.then(async () => {
|
|
13565
14620
|
outerLease = acquireRepairLease(resolved);
|
|
13566
|
-
|
|
14621
|
+
const storagePolicy = assertManagedStorageAllocation(resolved);
|
|
14622
|
+
assertStorageCapacity({
|
|
14623
|
+
stateDirectory: resolveStateDir(resolved),
|
|
14624
|
+
databasePath: resolveDbPath(resolved),
|
|
14625
|
+
operation: "vacuum",
|
|
14626
|
+
reserveBytes: storagePolicy?.minFreeBytes,
|
|
14627
|
+
});
|
|
14628
|
+
// Apply the existing repo-local exclusion before the candidate's
|
|
14629
|
+
// policy proof, not when opening its promoted live database.
|
|
14630
|
+
if (mayWriteToRepository(resolved))
|
|
14631
|
+
await excludeStateDirectoryLocally(resolved);
|
|
14632
|
+
before = await repairSourceAudit.run(newRepairSourceAuditState(), () => engine.status(resolved, {
|
|
13567
14633
|
audit: "deep",
|
|
13568
14634
|
signal: activeOperation.controller.signal,
|
|
13569
|
-
});
|
|
14635
|
+
}));
|
|
13570
14636
|
await suspendFileWatcher(resolved);
|
|
13571
14637
|
const activePath = resolveDbPath(resolved);
|
|
13572
14638
|
if (fs.existsSync(activePath)) {
|
|
@@ -13602,9 +14668,28 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13602
14668
|
if (staged.cancelled || !staged.verified) {
|
|
13603
14669
|
await engine.discardCandidate(candidate);
|
|
13604
14670
|
candidate = undefined;
|
|
13605
|
-
|
|
14671
|
+
let after = staged.cancelled
|
|
13606
14672
|
? before
|
|
13607
|
-
: await engine.status(resolved, { audit: "deep" });
|
|
14673
|
+
: await repairSourceAudit.run(newRepairSourceAuditState(), () => engine.status(resolved, { audit: "deep" }));
|
|
14674
|
+
if (!staged.cancelled) {
|
|
14675
|
+
// The active snapshot can pass ordinary graph checks while the
|
|
14676
|
+
// candidate lacked commit/source proof. Retain that refusal and
|
|
14677
|
+
// its actionable remediation rather than reporting zero issues.
|
|
14678
|
+
after = {
|
|
14679
|
+
...after,
|
|
14680
|
+
repairSteps: [...new Set([...staged.after.repairSteps, ...after.repairSteps])],
|
|
14681
|
+
...(after.missing.files.length + after.missing.records.length === 0
|
|
14682
|
+
? {
|
|
14683
|
+
status: "stale",
|
|
14684
|
+
freshness: { ...after.freshness, state: "unknown" },
|
|
14685
|
+
missing: {
|
|
14686
|
+
...after.missing,
|
|
14687
|
+
records: ["repair candidate could not establish a verified source snapshot"],
|
|
14688
|
+
},
|
|
14689
|
+
}
|
|
14690
|
+
: {}),
|
|
14691
|
+
};
|
|
14692
|
+
}
|
|
13608
14693
|
if (pendingCompleted)
|
|
13609
14694
|
deliver(pendingCompleted);
|
|
13610
14695
|
return {
|
|
@@ -13624,6 +14709,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13624
14709
|
if (!audit.healthy)
|
|
13625
14710
|
throw new Error(`repair candidate audit failed: ${audit.issues.join(", ")}`);
|
|
13626
14711
|
await engine.promoteCandidate(candidate);
|
|
14712
|
+
storagePromoted = true;
|
|
13627
14713
|
candidate = undefined;
|
|
13628
14714
|
const after = await engine.status(resolved, { audit: "deep" });
|
|
13629
14715
|
if (pendingCompleted)
|
|
@@ -13660,6 +14746,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13660
14746
|
if (candidate && candidates.has(candidate.id))
|
|
13661
14747
|
await engine.discardCandidate(candidate);
|
|
13662
14748
|
outerLease?.release();
|
|
14749
|
+
if (storagePromoted)
|
|
14750
|
+
completeStorageMaintenance(resolved);
|
|
13663
14751
|
options?.signal?.removeEventListener("abort", abortShared);
|
|
13664
14752
|
if (options?.onProgress)
|
|
13665
14753
|
activeOperation.subscribers.delete(options.onProgress);
|
|
@@ -13683,6 +14771,18 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13683
14771
|
async createCandidate(repoPath, options) {
|
|
13684
14772
|
const resolved = path.resolve(repoPath);
|
|
13685
14773
|
recoverInterruptedPromotion(resolved);
|
|
14774
|
+
if (options?.sourceDatabasePath) {
|
|
14775
|
+
const source = path.resolve(options.sourceDatabasePath);
|
|
14776
|
+
const storagePolicy = assertManagedStorageAllocation(resolved);
|
|
14777
|
+
assertStorageCapacity({
|
|
14778
|
+
stateDirectory: resolveStateDir(resolved),
|
|
14779
|
+
databasePath: resolveDbPath(resolved),
|
|
14780
|
+
operation: "candidate",
|
|
14781
|
+
reserveBytes: storagePolicy?.minFreeBytes,
|
|
14782
|
+
}, {
|
|
14783
|
+
measureFamily: () => measureDatabaseFamily(path.dirname(source), source),
|
|
14784
|
+
});
|
|
14785
|
+
}
|
|
13686
14786
|
const candidate = allocateCandidate(resolved);
|
|
13687
14787
|
try {
|
|
13688
14788
|
if (options?.empty) {
|
|
@@ -13695,7 +14795,16 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13695
14795
|
!fs.statSync(source).isFile() ||
|
|
13696
14796
|
fs.lstatSync(source).isSymbolicLink())
|
|
13697
14797
|
throw new Error("candidate source database must be a regular non-symbolic-link file");
|
|
13698
|
-
const
|
|
14798
|
+
const headerBytes = Buffer.alloc(16);
|
|
14799
|
+
const sourceDescriptor = fs.openSync(source, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
14800
|
+
let header;
|
|
14801
|
+
try {
|
|
14802
|
+
const count = fs.readSync(sourceDescriptor, headerBytes, 0, 16, 0);
|
|
14803
|
+
header = headerBytes.subarray(0, count).toString("binary");
|
|
14804
|
+
}
|
|
14805
|
+
finally {
|
|
14806
|
+
fs.closeSync(sourceDescriptor);
|
|
14807
|
+
}
|
|
13699
14808
|
if (header !== "SQLite format 3\u0000")
|
|
13700
14809
|
throw new Error("candidate source is not a SQLite database");
|
|
13701
14810
|
const { reflinked } = reflinkCopyFile(source, candidate.databasePath);
|
|
@@ -13721,6 +14830,22 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13721
14830
|
});
|
|
13722
14831
|
closeCandidateDatabase(candidate);
|
|
13723
14832
|
}
|
|
14833
|
+
if (!options?.empty) {
|
|
14834
|
+
const initializePolicy = storageInitializationPending(resolved);
|
|
14835
|
+
const stamp = new Database(candidate.databasePath);
|
|
14836
|
+
try {
|
|
14837
|
+
if (initializePolicy)
|
|
14838
|
+
stamp.run("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", [
|
|
14839
|
+
"storagePolicyInitializationPendingV1",
|
|
14840
|
+
"1",
|
|
14841
|
+
]);
|
|
14842
|
+
else
|
|
14843
|
+
stamp.run("DELETE FROM meta WHERE key = ?", ["storagePolicyInitializationPendingV1"]);
|
|
14844
|
+
}
|
|
14845
|
+
finally {
|
|
14846
|
+
stamp.close();
|
|
14847
|
+
}
|
|
14848
|
+
}
|
|
13724
14849
|
candidates.set(candidate.id, candidate);
|
|
13725
14850
|
return candidate;
|
|
13726
14851
|
}
|
|
@@ -13736,28 +14861,38 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13736
14861
|
throw new Error("candidate is not owned by this engine");
|
|
13737
14862
|
if (!fs.existsSync(candidate.databasePath))
|
|
13738
14863
|
throw new Error("candidate database is missing");
|
|
13739
|
-
const
|
|
13740
|
-
if (header !== "SQLite format 3\u0000")
|
|
13741
|
-
throw new Error("candidate is not a SQLite database");
|
|
13742
|
-
const probe = new Database(candidate.databasePath, { readonly: true });
|
|
14864
|
+
const bound = bindCandidateDatabase(candidate.repo, candidate);
|
|
13743
14865
|
try {
|
|
13744
|
-
const
|
|
13745
|
-
if (
|
|
13746
|
-
throw new Error("candidate
|
|
13747
|
-
const
|
|
13748
|
-
|
|
13749
|
-
|
|
14866
|
+
const header = bound.header.toString("binary");
|
|
14867
|
+
if (header !== "SQLite format 3\u0000")
|
|
14868
|
+
throw new Error("candidate is not a SQLite database");
|
|
14869
|
+
const probe = new Database(candidate.databasePath, { readonly: true });
|
|
14870
|
+
try {
|
|
14871
|
+
bound.verify();
|
|
14872
|
+
const integrity = probe.query("PRAGMA quick_check").all();
|
|
14873
|
+
if (integrity.length !== 1 || Object.values(integrity[0] ?? {})[0] !== "ok")
|
|
14874
|
+
throw new Error("candidate SQLite integrity check failed");
|
|
14875
|
+
const schemaVersion = probe.query("PRAGMA user_version").get()?.user_version ??
|
|
14876
|
+
0;
|
|
14877
|
+
if (schemaVersion !== KNODIN_SCHEMA_VERSION)
|
|
14878
|
+
throw new Error(`candidate schema ${schemaVersion} is incompatible with ${KNODIN_SCHEMA_VERSION}`);
|
|
14879
|
+
}
|
|
14880
|
+
finally {
|
|
14881
|
+
probe.close();
|
|
14882
|
+
}
|
|
14883
|
+
bound.verify();
|
|
14884
|
+
await getOrInitDb(candidate.repo, {
|
|
14885
|
+
databasePath: candidate.databasePath,
|
|
14886
|
+
watcher: "disabled",
|
|
14887
|
+
skipContentReconciliation: true,
|
|
14888
|
+
skipFreshnessGuard: true,
|
|
14889
|
+
});
|
|
14890
|
+
bound.verify();
|
|
14891
|
+
return candidate;
|
|
13750
14892
|
}
|
|
13751
14893
|
finally {
|
|
13752
|
-
|
|
14894
|
+
bound.close();
|
|
13753
14895
|
}
|
|
13754
|
-
await getOrInitDb(candidate.repo, {
|
|
13755
|
-
databasePath: candidate.databasePath,
|
|
13756
|
-
watcher: "disabled",
|
|
13757
|
-
skipContentReconciliation: true,
|
|
13758
|
-
skipFreshnessGuard: true,
|
|
13759
|
-
});
|
|
13760
|
-
return candidate;
|
|
13761
14896
|
},
|
|
13762
14897
|
async reconcileCandidate(candidate, repoPath, changes, options) {
|
|
13763
14898
|
const resolved = path.resolve(repoPath);
|
|
@@ -13767,6 +14902,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13767
14902
|
const db = dbInstances.get(databaseCacheKey(resolved, candidate.databasePath));
|
|
13768
14903
|
if (!db)
|
|
13769
14904
|
throw new Error("candidate database did not open");
|
|
14905
|
+
if (!fs.existsSync(resolveDbPath(resolved)))
|
|
14906
|
+
db.run("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", [
|
|
14907
|
+
"storagePolicyInitializationPendingV1",
|
|
14908
|
+
"1",
|
|
14909
|
+
]);
|
|
13770
14910
|
if (!changes) {
|
|
13771
14911
|
const oldCoverage = coverageForDatabase(db);
|
|
13772
14912
|
writeCoverageIntent(db, resolved, {
|
|
@@ -13782,8 +14922,14 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13782
14922
|
// build of a tree that has since moved on and promote a graph that
|
|
13783
14923
|
// never described any single state of the repository.
|
|
13784
14924
|
setMeta(db, CLEAN_INDEX_IN_PROGRESS_META_KEY, "1");
|
|
13785
|
-
|
|
13786
|
-
|
|
14925
|
+
const currentHead = gitHead(resolved) ?? "";
|
|
14926
|
+
if (options?.resume === true) {
|
|
14927
|
+
if (getMeta(db, CLEAN_INDEX_HEAD_META_KEY) !== currentHead)
|
|
14928
|
+
throw new Error("Repository HEAD changed before clean candidate resume; restart with knodin index --clean.");
|
|
14929
|
+
}
|
|
14930
|
+
else
|
|
14931
|
+
setMeta(db, CLEAN_INDEX_HEAD_META_KEY, currentHead);
|
|
14932
|
+
await indexRepo(resolved, db, options?.onProgress, options?.skipEmbeddings === true, false, options?.resume === true, true);
|
|
13787
14933
|
// Cleared only after indexRepo returns. Anything that fails or is
|
|
13788
14934
|
// killed above leaves it set, which is exactly what makes the
|
|
13789
14935
|
// candidate recognisable as resumable rather than abandoned.
|
|
@@ -13820,18 +14966,71 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13820
14966
|
// A candidate populated from a foreign source (a downloaded shared-index
|
|
13821
14967
|
// artifact, or another worktree's database) carries index_state mtimes
|
|
13822
14968
|
// stamped wherever that source was built -- not this `resolved` checkout.
|
|
13823
|
-
//
|
|
13824
|
-
// to
|
|
13825
|
-
//
|
|
13826
|
-
//
|
|
14969
|
+
// A diff alone is not a source proof. Transfer only an original parsed
|
|
14970
|
+
// hash bound to the copied tuple and identical, stable target bytes.
|
|
14971
|
+
// Missing or invalid evidence requires extraction, never a new hash
|
|
14972
|
+
// invented over potentially stale symbols.
|
|
13827
14973
|
const reconciledSet = new Set(reconciled);
|
|
13828
14974
|
const knownPaths = db
|
|
13829
|
-
.query("SELECT filePath FROM index_state")
|
|
14975
|
+
.query("SELECT filePath,mtimeMs,size FROM index_state")
|
|
13830
14976
|
.all()
|
|
13831
|
-
.
|
|
13832
|
-
|
|
13833
|
-
for (const
|
|
13834
|
-
|
|
14977
|
+
.filter((row) => !reconciledSet.has(row.filePath));
|
|
14978
|
+
const seedBytes = Buffer.alloc(64 * 1024);
|
|
14979
|
+
for (const row of knownPaths) {
|
|
14980
|
+
const file = row.filePath;
|
|
14981
|
+
let transferred = false;
|
|
14982
|
+
try {
|
|
14983
|
+
const raw = db
|
|
14984
|
+
.query("SELECT value FROM meta WHERE key=? AND length(value)<=512")
|
|
14985
|
+
.get(`${REPAIR_CHECKPOINT_PREFIX}${file}`)?.value;
|
|
14986
|
+
const proof = raw ? JSON.parse(raw) : null;
|
|
14987
|
+
if (proof?.version === 1 &&
|
|
14988
|
+
typeof proof.sourceHash === "string" &&
|
|
14989
|
+
/^[a-f0-9]{64}$/.test(proof.sourceHash) &&
|
|
14990
|
+
proof.mtimeMs === row.mtimeMs &&
|
|
14991
|
+
proof.size === row.size) {
|
|
14992
|
+
const before = fs.statSync(path.join(resolved, file));
|
|
14993
|
+
const hash = repairSourceFingerprint(resolved, file, seedBytes);
|
|
14994
|
+
const after = fs.statSync(path.join(resolved, file));
|
|
14995
|
+
if (hash === proof.sourceHash &&
|
|
14996
|
+
before.mtimeMs === after.mtimeMs &&
|
|
14997
|
+
before.size === after.size &&
|
|
14998
|
+
before.ctimeMs === after.ctimeMs &&
|
|
14999
|
+
before.ino === after.ino &&
|
|
15000
|
+
before.dev === after.dev) {
|
|
15001
|
+
recordIndexState(db, resolved, file);
|
|
15002
|
+
const recorded = db
|
|
15003
|
+
.query("SELECT filePath,mtimeMs,size FROM index_state WHERE filePath=?")
|
|
15004
|
+
.get(file);
|
|
15005
|
+
const final = fs.statSync(path.join(resolved, file));
|
|
15006
|
+
if (recorded?.mtimeMs === after.mtimeMs &&
|
|
15007
|
+
recorded.size === after.size &&
|
|
15008
|
+
final.mtimeMs === after.mtimeMs &&
|
|
15009
|
+
final.size === after.size &&
|
|
15010
|
+
final.ctimeMs === after.ctimeMs &&
|
|
15011
|
+
final.ino === after.ino &&
|
|
15012
|
+
final.dev === after.dev) {
|
|
15013
|
+
setMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${file}`, JSON.stringify({
|
|
15014
|
+
version: 1,
|
|
15015
|
+
sourceHash: proof.sourceHash,
|
|
15016
|
+
mtimeMs: after.mtimeMs,
|
|
15017
|
+
size: after.size,
|
|
15018
|
+
}));
|
|
15019
|
+
transferred = true;
|
|
15020
|
+
}
|
|
15021
|
+
}
|
|
15022
|
+
}
|
|
15023
|
+
}
|
|
15024
|
+
catch {
|
|
15025
|
+
/* Missing or malformed foreign proof requires extraction. */
|
|
15026
|
+
}
|
|
15027
|
+
if (!transferred) {
|
|
15028
|
+
await indexFile(path.join(resolved, file), file, resolved, db);
|
|
15029
|
+
recordIndexState(db, resolved, file);
|
|
15030
|
+
reconciled.push(file);
|
|
15031
|
+
reconciledSet.add(file);
|
|
15032
|
+
}
|
|
15033
|
+
}
|
|
13835
15034
|
const refreshed = await refreshFlowsForApexFiles(db, resolved, reconciled);
|
|
13836
15035
|
for (const file of refreshed) {
|
|
13837
15036
|
if (!reconciledSet.has(file))
|
|
@@ -13896,6 +15095,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13896
15095
|
issues.push("orphaned-embeddings");
|
|
13897
15096
|
}
|
|
13898
15097
|
if (tables.has("index_state")) {
|
|
15098
|
+
const epoch = validSourceEpoch(getMeta(db, CONTENT_PROOF_EPOCH_KEY));
|
|
15099
|
+
if (hasGitCheckout(candidate.repo) &&
|
|
15100
|
+
(epoch === null ||
|
|
15101
|
+
(getMeta(db, "lastIndexedHead") ?? "") !== (gitHead(candidate.repo) ?? "")))
|
|
15102
|
+
issues.push("source-provenance");
|
|
13899
15103
|
if (missingApexConsumerDescriptorFiles(db).length)
|
|
13900
15104
|
issues.push("apex-consumer-descriptors");
|
|
13901
15105
|
if ((db
|
|
@@ -13915,6 +15119,19 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13915
15119
|
issues.push("ineligible-index-state");
|
|
13916
15120
|
if (indexed.some((row) => fileDriftedFromIndexState(db, candidate.repo, row.filePath)))
|
|
13917
15121
|
issues.push("source-snapshot-drift");
|
|
15122
|
+
if (epoch !== null) {
|
|
15123
|
+
const readCheckpoint = repairCheckpointReader(db, candidate.repo);
|
|
15124
|
+
if (indexed.some((row) => {
|
|
15125
|
+
try {
|
|
15126
|
+
return (fs.statSync(path.join(candidate.repo, row.filePath)).ctimeMs >= epoch - 1 &&
|
|
15127
|
+
!readCheckpoint(row.filePath, row)?.matches);
|
|
15128
|
+
}
|
|
15129
|
+
catch {
|
|
15130
|
+
return true;
|
|
15131
|
+
}
|
|
15132
|
+
}))
|
|
15133
|
+
issues.push("source-content-drift");
|
|
15134
|
+
}
|
|
13918
15135
|
}
|
|
13919
15136
|
}
|
|
13920
15137
|
finally {
|
|
@@ -13931,36 +15148,75 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
13931
15148
|
databaseSha256,
|
|
13932
15149
|
};
|
|
13933
15150
|
},
|
|
15151
|
+
finalizePromotedStorage: completeStorageMaintenance,
|
|
13934
15152
|
async promoteCandidate(candidate) {
|
|
13935
15153
|
assertCandidate(candidate.repo, candidate);
|
|
13936
|
-
const
|
|
13937
|
-
|
|
13938
|
-
|
|
13939
|
-
|
|
13940
|
-
|
|
13941
|
-
|
|
13942
|
-
|
|
13943
|
-
|
|
13944
|
-
|
|
13945
|
-
|
|
13946
|
-
|
|
13947
|
-
|
|
13948
|
-
|
|
13949
|
-
|
|
13950
|
-
|
|
13951
|
-
|
|
13952
|
-
|
|
13953
|
-
|
|
13954
|
-
|
|
13955
|
-
|
|
13956
|
-
|
|
13957
|
-
|
|
13958
|
-
|
|
13959
|
-
|
|
13960
|
-
|
|
13961
|
-
|
|
13962
|
-
|
|
13963
|
-
|
|
15154
|
+
const bound = bindCandidateDatabase(candidate.repo, candidate);
|
|
15155
|
+
try {
|
|
15156
|
+
const auditedDigest = candidateAudits.get(candidate.id);
|
|
15157
|
+
if (!auditedDigest)
|
|
15158
|
+
throw new Error("candidate must pass deep audit before promotion");
|
|
15159
|
+
const confirmation = await engine.auditCandidate(candidate, {
|
|
15160
|
+
mode: "deep",
|
|
15161
|
+
});
|
|
15162
|
+
if (!confirmation.healthy || confirmation.databaseSha256 !== auditedDigest)
|
|
15163
|
+
throw new Error("candidate or repository changed after deep audit");
|
|
15164
|
+
closeCandidateDatabase(candidate);
|
|
15165
|
+
if (candidateDigest(candidate) !== auditedDigest)
|
|
15166
|
+
throw new Error("candidate changed after deep audit");
|
|
15167
|
+
const resolved = candidate.repo;
|
|
15168
|
+
await suspendFileWatcher(resolved);
|
|
15169
|
+
// Watcher shutdown yields. Re-check source and original HEAD after
|
|
15170
|
+
// that last async boundary, immediately before the atomic swap.
|
|
15171
|
+
const finalConfirmation = await engine.auditCandidate(candidate, { mode: "deep" });
|
|
15172
|
+
if (!finalConfirmation.healthy || finalConfirmation.databaseSha256 !== auditedDigest)
|
|
15173
|
+
throw new Error("candidate or repository changed before promotion");
|
|
15174
|
+
// The public async audit itself yields on return. A final synchronous
|
|
15175
|
+
// source/Git fence follows it; no voluntary yield remains before swap.
|
|
15176
|
+
bound.verify();
|
|
15177
|
+
const finalSourceDb = new Database(candidate.databasePath, { readonly: true });
|
|
15178
|
+
try {
|
|
15179
|
+
bound.verify();
|
|
15180
|
+
const epoch = validSourceEpoch(getMeta(finalSourceDb, CONTENT_PROOF_EPOCH_KEY));
|
|
15181
|
+
const gitBacked = hasGitCheckout(resolved);
|
|
15182
|
+
const intent = requireIndexCoverage(coverageForDatabase(finalSourceDb));
|
|
15183
|
+
const git = gitBacked ? gitWorkTreeProbe(resolved, intent, finalSourceDb) : null;
|
|
15184
|
+
if ((gitBacked &&
|
|
15185
|
+
(epoch === null ||
|
|
15186
|
+
!git ||
|
|
15187
|
+
(getMeta(finalSourceDb, "lastIndexedHead") ?? "") !== git.head ||
|
|
15188
|
+
getMeta(finalSourceDb, "lastProbePolicy") !== git.policyFingerprint ||
|
|
15189
|
+
getMeta(finalSourceDb, "lastProbeDirty") !==
|
|
15190
|
+
coverageDirtyFingerprint(intent, git.dirty.filter((file) => coverageContains(intent, file))))) ||
|
|
15191
|
+
!sourceSnapshotMatches(finalSourceDb, resolved, epoch ?? Number.POSITIVE_INFINITY))
|
|
15192
|
+
throw new Error("Repository source changed at final promotion fence");
|
|
15193
|
+
}
|
|
15194
|
+
finally {
|
|
15195
|
+
finalSourceDb.close();
|
|
15196
|
+
}
|
|
15197
|
+
closeCandidateDatabase(candidate);
|
|
15198
|
+
if (candidateDigest(candidate) !== auditedDigest)
|
|
15199
|
+
throw new Error("candidate changed at final promotion fence");
|
|
15200
|
+
bound.verify();
|
|
15201
|
+
const active = dbInstances.get(resolved);
|
|
15202
|
+
if (active) {
|
|
15203
|
+
active.run("PRAGMA wal_checkpoint(TRUNCATE);");
|
|
15204
|
+
active.close();
|
|
15205
|
+
}
|
|
15206
|
+
dbInstances.delete(resolved);
|
|
15207
|
+
initPromises.delete(resolved);
|
|
15208
|
+
for (const suffix of ["-wal", "-shm"])
|
|
15209
|
+
fs.rmSync(`${resolveDbPath(resolved)}${suffix}`, { force: true });
|
|
15210
|
+
const backupPath = promoteCandidateFile(resolved, candidate);
|
|
15211
|
+
candidates.delete(candidate.id);
|
|
15212
|
+
candidateAudits.delete(candidate.id);
|
|
15213
|
+
invalidateResolutionCaches(resolved);
|
|
15214
|
+
indexGeneration++;
|
|
15215
|
+
return { backupPath };
|
|
15216
|
+
}
|
|
15217
|
+
finally {
|
|
15218
|
+
bound.close();
|
|
15219
|
+
}
|
|
13964
15220
|
},
|
|
13965
15221
|
async discardCandidate(candidate) {
|
|
13966
15222
|
assertCandidate(candidate.repo, candidate);
|
|
@@ -14133,9 +15389,14 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14133
15389
|
allCallees.length > cap ||
|
|
14134
15390
|
allBlastFiles.length > cap ||
|
|
14135
15391
|
omissions.length > cap;
|
|
15392
|
+
const selectedDb = allRepos.find((entry) => entry.path === targetRepoPath)?.db;
|
|
15393
|
+
const platformEntry = selectedDb
|
|
15394
|
+
? await explainPlatformEntry(selectedDb, targetRepoPath, primaryDef, formatPath(targetRepoPath, primaryDef.filePath))
|
|
15395
|
+
: undefined;
|
|
14136
15396
|
return {
|
|
14137
15397
|
...(indexCoverage ? { indexCoverage } : {}),
|
|
14138
15398
|
identity: symbolIdentity(targetRepoPath, primaryDef),
|
|
15399
|
+
...(platformEntry ? { platformEntry } : {}),
|
|
14139
15400
|
symbol,
|
|
14140
15401
|
summary,
|
|
14141
15402
|
source,
|
|
@@ -14981,7 +16242,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14981
16242
|
const persisted = readPersistedStatusAudit(resolved, diskDbPath, databaseFingerprint, schemaVersion);
|
|
14982
16243
|
if (persisted) {
|
|
14983
16244
|
throwIfAborted(options.signal);
|
|
14984
|
-
const gitProbe = measurePerfPhaseSync("freshness_probe", () => gitWorkTreeProbe(resolved, requireIndexCoverage(coverageForDatabase(db))));
|
|
16245
|
+
const gitProbe = measurePerfPhaseSync("freshness_probe", () => gitWorkTreeProbe(resolved, requireIndexCoverage(coverageForDatabase(db)), db));
|
|
14985
16246
|
const drift = probeDrift(resolved, db, gitProbe);
|
|
14986
16247
|
const probeVerifiedAt = new Date().toISOString();
|
|
14987
16248
|
const freshness = buildFreshnessEnvelope(resolved, db, probeVerifiedAt, drift.verifiable ? (drift.drifted ? "stale-working-tree" : undefined) : "unknown", gitProbe);
|
|
@@ -15151,6 +16412,12 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15151
16412
|
const staleFiles = [];
|
|
15152
16413
|
const ineligibleFiles = [];
|
|
15153
16414
|
const damagedFiles = [];
|
|
16415
|
+
const sourceEpoch = validSourceEpoch(getMeta(db, CONTENT_PROOF_EPOCH_KEY)) ?? Number.NaN;
|
|
16416
|
+
// This extra fence addresses Git's stat-cache blind spots. Plain
|
|
16417
|
+
// directories retain their existing committed-file resume checkpoints.
|
|
16418
|
+
const sourceAudit = repairSourceAudit.getStore();
|
|
16419
|
+
const auditSourceEpoch = hasGitCheckout(resolved);
|
|
16420
|
+
const readCheckpoint = auditSourceEpoch ? repairCheckpointReader(db, resolved) : null;
|
|
15154
16421
|
for (let index = 0; index < indexedRows.length; index++) {
|
|
15155
16422
|
if (options.signal && index % 256 === 0) {
|
|
15156
16423
|
throwIfAborted(options.signal);
|
|
@@ -15164,7 +16431,20 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15164
16431
|
}
|
|
15165
16432
|
try {
|
|
15166
16433
|
const stat = fs.statSync(path.join(resolved, row.filePath));
|
|
15167
|
-
|
|
16434
|
+
const epochCandidate = auditSourceEpoch && (!Number.isFinite(sourceEpoch) || stat.ctimeMs >= sourceEpoch - 1);
|
|
16435
|
+
const proof = epochCandidate ? readCheckpoint?.(row.filePath, row) : null;
|
|
16436
|
+
if (proof)
|
|
16437
|
+
sourceAudit?.owned.add(row.filePath);
|
|
16438
|
+
if (proof?.matches)
|
|
16439
|
+
sourceAudit?.verified.set(row.filePath, proof.sourceHash);
|
|
16440
|
+
// Git may trust restored size/mtime or a coarse ctime cache. The
|
|
16441
|
+
// existing deep stat pass can still select source changed after
|
|
16442
|
+
// our completed snapshot, without hashing the entire repository.
|
|
16443
|
+
// ISO metadata has millisecond precision; include its boundary.
|
|
16444
|
+
if (stat.size !== row.size ||
|
|
16445
|
+
stat.mtimeMs !== row.mtimeMs ||
|
|
16446
|
+
(epochCandidate && Number.isFinite(sourceEpoch) && !proof?.matches) ||
|
|
16447
|
+
(epochCandidate && proof !== null && proof !== undefined && !proof.matches))
|
|
15168
16448
|
damagedFiles.push(row.filePath);
|
|
15169
16449
|
}
|
|
15170
16450
|
catch {
|
|
@@ -15182,6 +16462,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15182
16462
|
AND NOT (d.fromFile LIKE '.knodin/dbt/%' AND EXISTS (SELECT 1 FROM symbols s WHERE s.filePath=d.fromFile))`),
|
|
15183
16463
|
};
|
|
15184
16464
|
const records = staleFiles.map((file) => `indexed file no longer exists: ${file}`);
|
|
16465
|
+
if (auditSourceEpoch && indexedRows.length > 0 && !Number.isFinite(sourceEpoch))
|
|
16466
|
+
records.push(LEGACY_CONTENT_PROOF_RECORD);
|
|
15185
16467
|
for (const file of missingApexConsumerDescriptorFiles(db))
|
|
15186
16468
|
records.push(`Apex class-consumer descriptor missing or invalid: ${file}`);
|
|
15187
16469
|
if (indexedRows.some((row) => /\.(cls|trigger)$/i.test(row.filePath)) &&
|
|
@@ -15202,7 +16484,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15202
16484
|
if (coverageForDatabase(db).intentState === "pending")
|
|
15203
16485
|
records.push("index coverage operation is pending");
|
|
15204
16486
|
const needsRepair = missingFiles.length > 0 || records.length > 0;
|
|
15205
|
-
const freshness = buildFreshnessEnvelope(resolved, db, verifiedAt, needsRepair ? "repair-needed" : undefined);
|
|
16487
|
+
const freshness = buildFreshnessEnvelope(resolved, db, verifiedAt, needsRepair ? "repair-needed" : undefined, undefined, !needsRepair);
|
|
15206
16488
|
const stale = !needsRepair && freshness.state !== "fresh";
|
|
15207
16489
|
const result = {
|
|
15208
16490
|
indexCoverage: indexCoverageSummary(coverageForDatabase(db)),
|
|
@@ -15245,7 +16527,9 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15245
16527
|
freshnessMechanism: freshnessMechanismFor(resolved, openPolicy),
|
|
15246
16528
|
repairSteps: needsRepair
|
|
15247
16529
|
? [
|
|
15248
|
-
|
|
16530
|
+
auditSourceEpoch && !Number.isFinite(sourceEpoch) && indexedRows.length > 0
|
|
16531
|
+
? LEGACY_CONTENT_PROOF_REPAIR_STEP
|
|
16532
|
+
: "Run `knodin repair` to rebuild only missing or damaged local state.",
|
|
15249
16533
|
"Run `knodin status` again to verify health.",
|
|
15250
16534
|
]
|
|
15251
16535
|
: [],
|
|
@@ -15366,10 +16650,11 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15366
16650
|
indexGeneration++;
|
|
15367
16651
|
};
|
|
15368
16652
|
emitProgress("audit", 0, "Auditing repository health");
|
|
15369
|
-
|
|
16653
|
+
const sourceAudit = newRepairSourceAuditState();
|
|
16654
|
+
before = await repairSourceAudit.run(sourceAudit, () => engine.status(resolved, {
|
|
15370
16655
|
audit: "deep",
|
|
15371
16656
|
signal: activeOperation.controller.signal,
|
|
15372
|
-
});
|
|
16657
|
+
}));
|
|
15373
16658
|
emitProgress("audit", 1, "Repository health audit completed", {
|
|
15374
16659
|
phaseTotal: 1,
|
|
15375
16660
|
});
|
|
@@ -15454,11 +16739,85 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15454
16739
|
committedWork = true;
|
|
15455
16740
|
const inheritedPostprocessing = getMeta(db, "repairPostprocessingPending") === "1";
|
|
15456
16741
|
const eligibleRepairPaths = new Set(collectRepoFiles(resolved, db));
|
|
16742
|
+
const repairIntent = requireIndexCoverage(coverageForDatabase(db));
|
|
16743
|
+
const repairGit = gitWorkTreeProbe(resolved, repairIntent, db);
|
|
16744
|
+
const gitBacked = hasGitCheckout(resolved);
|
|
16745
|
+
let baselineProvable = !gitBacked || repairGit !== null;
|
|
16746
|
+
const contentRepairPaths = new Set(repairGit?.dirty ?? []);
|
|
16747
|
+
const indexedCount = db.query("SELECT COUNT(*) AS count FROM index_state").get()
|
|
16748
|
+
?.count ?? 0;
|
|
16749
|
+
const emptyIndex = indexedCount === 0;
|
|
16750
|
+
const initializingResume = getMeta(db, REPAIR_INITIALIZATION_KEY) === "1" &&
|
|
16751
|
+
sourceAudit.owned.size === indexedCount;
|
|
16752
|
+
if (gitBacked && emptyIndex)
|
|
16753
|
+
setMeta(db, REPAIR_INITIALIZATION_KEY, "1");
|
|
16754
|
+
const contentProofEpoch = validSourceEpoch(getMeta(db, CONTENT_PROOF_EPOCH_KEY), startedAt);
|
|
16755
|
+
const sourceEpochProvable = !gitBacked || emptyIndex || initializingResume || contentProofEpoch !== null;
|
|
16756
|
+
baselineProvable &&= sourceEpochProvable;
|
|
16757
|
+
// A Git dirty path is content evidence even if size and mtime were
|
|
16758
|
+
// restored. Do not let the stat optimization suppress its repair.
|
|
16759
|
+
// Missing dirty baselines can be recovered from this complete probe;
|
|
16760
|
+
// a missing/unresolvable commit baseline cannot be guessed.
|
|
16761
|
+
if (repairGit) {
|
|
16762
|
+
const indexedHead = getMeta(db, "lastIndexedHead") ?? "";
|
|
16763
|
+
if (indexedHead !== repairGit.head) {
|
|
16764
|
+
if (!indexedHead && (emptyIndex || initializingResume)) {
|
|
16765
|
+
// First initialization has no historical snapshot to trust.
|
|
16766
|
+
// Its complete eligible inventory is rebuilt below, so it may
|
|
16767
|
+
// establish the first commit baseline after that content work.
|
|
16768
|
+
for (const file of eligibleRepairPaths) {
|
|
16769
|
+
if (!sourceAudit.verified.has(file))
|
|
16770
|
+
contentRepairPaths.add(file);
|
|
16771
|
+
}
|
|
16772
|
+
}
|
|
16773
|
+
else if (!indexedHead || !repairGit.head)
|
|
16774
|
+
baselineProvable = false;
|
|
16775
|
+
else {
|
|
16776
|
+
try {
|
|
16777
|
+
for (const pathspecs of coverageGitPathspecBatches(repairIntent)) {
|
|
16778
|
+
const diff = child_process.execFileSync("git", [
|
|
16779
|
+
"diff",
|
|
16780
|
+
"--name-only",
|
|
16781
|
+
"-z",
|
|
16782
|
+
indexedHead,
|
|
16783
|
+
repairGit.head,
|
|
16784
|
+
...(pathspecs.length ? pathspecs : ["--"]),
|
|
16785
|
+
], {
|
|
16786
|
+
cwd: resolved,
|
|
16787
|
+
encoding: "utf8",
|
|
16788
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16789
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
16790
|
+
});
|
|
16791
|
+
for (const file of splitNulPaths(diff))
|
|
16792
|
+
contentRepairPaths.add(file);
|
|
16793
|
+
}
|
|
16794
|
+
}
|
|
16795
|
+
catch {
|
|
16796
|
+
baselineProvable = false;
|
|
16797
|
+
}
|
|
16798
|
+
}
|
|
16799
|
+
}
|
|
16800
|
+
}
|
|
16801
|
+
const readCheckpoint = repairCheckpointReader(db, resolved);
|
|
16802
|
+
for (const file of contentRepairPaths) {
|
|
16803
|
+
if (!eligibleRepairPaths.has(file))
|
|
16804
|
+
continue;
|
|
16805
|
+
const proof = readCheckpoint(file);
|
|
16806
|
+
if (proof?.matches)
|
|
16807
|
+
sourceAudit.verified.set(file, proof.sourceHash);
|
|
16808
|
+
else if (!repairKinds.has(file))
|
|
16809
|
+
repairKinds.set(file, "damaged");
|
|
16810
|
+
}
|
|
15457
16811
|
repairPaths = [...repairKinds.keys()]
|
|
15458
16812
|
.filter((file) => repairKinds.get(file) === "stale" ||
|
|
15459
16813
|
descriptorRepairPaths.has(file) ||
|
|
16814
|
+
repairKinds.get(file) === "damaged" ||
|
|
16815
|
+
contentRepairPaths.has(file) ||
|
|
15460
16816
|
fileDriftedFromIndexState(db, resolved, file))
|
|
15461
16817
|
.sort(compareBytes);
|
|
16818
|
+
const repairBytes = Buffer.alloc(64 * 1024);
|
|
16819
|
+
const repairedContent = new Map(sourceAudit.verified);
|
|
16820
|
+
const repairedState = db.query("SELECT filePath,mtimeMs,size FROM index_state WHERE filePath=?");
|
|
15462
16821
|
const repairAffectsTypeScriptDi = inheritedPostprocessing || repairPathsAffectTypeScriptDi(db, resolved, repairPaths);
|
|
15463
16822
|
counts.skipped = Math.max(0, repairKinds.size - repairPaths.length);
|
|
15464
16823
|
overallTotal = repairPaths.length + 4;
|
|
@@ -15477,14 +16836,29 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15477
16836
|
// reports repair-needed on every subsequent run.
|
|
15478
16837
|
const shouldRemove = !eligibleRepairPaths.has(file) || !fs.existsSync(absolutePath);
|
|
15479
16838
|
if (!shouldRemove) {
|
|
16839
|
+
repairedContent.set(file, repairSourceFingerprint(resolved, file, repairBytes));
|
|
15480
16840
|
await indexFile(absolutePath, file, resolved, db);
|
|
16841
|
+
if (repairSourceFingerprint(resolved, file, repairBytes) !== repairedContent.get(file))
|
|
16842
|
+
throw new Error("Source changed while repairing a file; checkpoint not recorded");
|
|
15481
16843
|
recordIndexState(db, resolved, file);
|
|
16844
|
+
if (gitBacked) {
|
|
16845
|
+
const row = repairedState.get(file);
|
|
16846
|
+
if (!row)
|
|
16847
|
+
throw new Error("Repaired source has no index-state checkpoint");
|
|
16848
|
+
setMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${file}`, JSON.stringify({
|
|
16849
|
+
version: 1,
|
|
16850
|
+
sourceHash: repairedContent.get(file),
|
|
16851
|
+
mtimeMs: row.mtimeMs,
|
|
16852
|
+
size: row.size,
|
|
16853
|
+
}));
|
|
16854
|
+
}
|
|
15482
16855
|
if (repairKinds.get(file) === "damaged")
|
|
15483
16856
|
counts.rebuilt++;
|
|
15484
16857
|
else
|
|
15485
16858
|
counts.indexed++;
|
|
15486
16859
|
}
|
|
15487
16860
|
else if (shouldPurgeMissingFile(db)) {
|
|
16861
|
+
repairedContent.delete(file);
|
|
15488
16862
|
db.run("BEGIN TRANSACTION;");
|
|
15489
16863
|
try {
|
|
15490
16864
|
deleteSymbolsForFile(db, file);
|
|
@@ -15632,40 +17006,67 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15632
17006
|
phaseTotal: 1,
|
|
15633
17007
|
});
|
|
15634
17008
|
cancelBoundary();
|
|
15635
|
-
let after = await engine.status(resolved, {
|
|
17009
|
+
let after = await repairSourceAudit.run(newRepairSourceAuditState(), () => engine.status(resolved, {
|
|
15636
17010
|
audit: "deep",
|
|
15637
17011
|
signal: activeOperation.controller.signal,
|
|
15638
|
-
});
|
|
17012
|
+
}));
|
|
15639
17013
|
cancelBoundary();
|
|
15640
17014
|
overallCompleted++;
|
|
15641
17015
|
emitProgress("verification", 1, "Repair verification completed", {
|
|
15642
17016
|
phaseTotal: 1,
|
|
15643
17017
|
});
|
|
15644
17018
|
cancelBoundary();
|
|
15645
|
-
|
|
15646
|
-
//
|
|
15647
|
-
//
|
|
15648
|
-
//
|
|
15649
|
-
const
|
|
17019
|
+
{
|
|
17020
|
+
// Deep audit proves inventory/stat integrity, not source bytes.
|
|
17021
|
+
// Dirty and committed-diff sources above were forcibly indexed;
|
|
17022
|
+
// content and Git fences must still agree before publishing.
|
|
17023
|
+
const finalGit = gitWorkTreeProbe(resolved, repairIntent, db);
|
|
17024
|
+
const gitStable = !gitBacked ||
|
|
17025
|
+
(repairGit !== null &&
|
|
17026
|
+
finalGit !== null &&
|
|
17027
|
+
repairGit.head === finalGit.head &&
|
|
17028
|
+
repairGit.policyFingerprint === finalGit.policyFingerprint &&
|
|
17029
|
+
repairGit.statusFingerprint === finalGit.statusFingerprint);
|
|
17030
|
+
let sourceStable = true;
|
|
17031
|
+
for (const [file, fingerprint] of repairedContent) {
|
|
17032
|
+
cancelBoundary();
|
|
17033
|
+
if (repairSourceFingerprint(resolved, file, repairBytes) !== fingerprint)
|
|
17034
|
+
sourceStable = false;
|
|
17035
|
+
}
|
|
17036
|
+
const contentVerified = baselineProvable &&
|
|
17037
|
+
gitStable &&
|
|
17038
|
+
sourceStable &&
|
|
17039
|
+
after.coverage.sourceFiles === after.coverage.indexedFiles &&
|
|
15650
17040
|
after.missing.files.length === 0 &&
|
|
15651
|
-
after.missing.records.every((record) => record === "index coverage operation is pending"
|
|
17041
|
+
after.missing.records.every((record) => record === "index coverage operation is pending" ||
|
|
17042
|
+
(sourceEpochProvable && record === LEGACY_CONTENT_PROOF_RECORD)) &&
|
|
15652
17043
|
Object.values(after.orphaned).every((count) => count === 0);
|
|
15653
17044
|
if (contentVerified) {
|
|
15654
17045
|
db.run("BEGIN TRANSACTION;");
|
|
15655
17046
|
try {
|
|
15656
17047
|
markCoverageIntentComplete(db);
|
|
15657
|
-
if (
|
|
15658
|
-
setMeta(db, "lastIndexedHead",
|
|
15659
|
-
}
|
|
17048
|
+
if (repairGit)
|
|
17049
|
+
setMeta(db, "lastIndexedHead", repairGit.head);
|
|
15660
17050
|
setMeta(db, "knodinVersion", KNODIN_VERSION);
|
|
15661
17051
|
setMeta(db, "lastSuccessfulReconciliation", new Date().toISOString());
|
|
17052
|
+
setMeta(db, CONTENT_PROOF_EPOCH_KEY, new Date(startedAt).toISOString());
|
|
15662
17053
|
recordFreshnessBaseline(resolved, db);
|
|
15663
17054
|
statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
|
|
15664
|
-
const candidate = await engine.status(resolved, {
|
|
17055
|
+
const candidate = await repairSourceAudit.run(newRepairSourceAuditState(), () => engine.status(resolved, {
|
|
15665
17056
|
audit: "deep",
|
|
15666
17057
|
signal: activeOperation.controller.signal,
|
|
15667
|
-
});
|
|
17058
|
+
}));
|
|
17059
|
+
for (const [file, fingerprint] of repairedContent) {
|
|
17060
|
+
cancelBoundary();
|
|
17061
|
+
if (repairSourceFingerprint(resolved, file, repairBytes) !== fingerprint)
|
|
17062
|
+
throw new Error("Source changed during repair verification; baseline not published");
|
|
17063
|
+
}
|
|
15668
17064
|
if (candidate.status === "healthy") {
|
|
17065
|
+
if (!sourceSnapshotMatches(db, resolved, startedAt))
|
|
17066
|
+
throw new Error("Source changed during final repair verification; baseline not published");
|
|
17067
|
+
// Keep bounded per-file proofs for source changed during this
|
|
17068
|
+
// epoch so ordinary deep audits can verify equal-stat edits.
|
|
17069
|
+
db.run("DELETE FROM meta WHERE key=?", [REPAIR_INITIALIZATION_KEY]);
|
|
15669
17070
|
db.run("COMMIT;");
|
|
15670
17071
|
after = candidate;
|
|
15671
17072
|
}
|
|
@@ -15684,6 +17085,20 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15684
17085
|
throw error;
|
|
15685
17086
|
}
|
|
15686
17087
|
}
|
|
17088
|
+
else if (!baselineProvable || !gitStable || !sourceStable) {
|
|
17089
|
+
after = {
|
|
17090
|
+
...after,
|
|
17091
|
+
status: "stale",
|
|
17092
|
+
freshness: { ...after.freshness, state: "unknown" },
|
|
17093
|
+
repairSteps: [
|
|
17094
|
+
!sourceEpochProvable
|
|
17095
|
+
? "Run knodin index --clean once to establish a versioned content-proof epoch."
|
|
17096
|
+
: repairGit && !baselineProvable
|
|
17097
|
+
? "Restore the indexed Git commit history, or explicitly run knodin index --clean to rebuild without the missing baseline."
|
|
17098
|
+
: "Restore a verifiable Git/source snapshot and run knodin repair again.",
|
|
17099
|
+
],
|
|
17100
|
+
};
|
|
17101
|
+
}
|
|
15687
17102
|
}
|
|
15688
17103
|
const outstandingIssues = {
|
|
15689
17104
|
files: after.missing.files.length,
|
|
@@ -15758,6 +17173,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15758
17173
|
},
|
|
15759
17174
|
async index(repoPath, files, clean = false, options) {
|
|
15760
17175
|
const resolved = path.resolve(repoPath);
|
|
17176
|
+
const newStore = !openPolicy.databasePath && !fs.existsSync(resolveDbPath(resolved));
|
|
17177
|
+
let storageCommitted = false;
|
|
15761
17178
|
const scopedUnder = options?.under;
|
|
15762
17179
|
const normalizeSelector = (file, allowRoot = false) => {
|
|
15763
17180
|
const relative = path
|
|
@@ -15813,6 +17230,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15813
17230
|
prior.close();
|
|
15814
17231
|
}
|
|
15815
17232
|
}
|
|
17233
|
+
if (newStore) {
|
|
17234
|
+
const storagePolicy = assertManagedStorageAllocation(resolved);
|
|
17235
|
+
assertStorageCapacity({
|
|
17236
|
+
stateDirectory: resolveStateDir(resolved),
|
|
17237
|
+
databasePath: resolveDbPath(resolved),
|
|
17238
|
+
operation: "candidate",
|
|
17239
|
+
reserveBytes: storagePolicy?.minFreeBytes,
|
|
17240
|
+
});
|
|
17241
|
+
}
|
|
15816
17242
|
const indexCoordination = acquireLifecycleCoordination({
|
|
15817
17243
|
command: "index",
|
|
15818
17244
|
repository: resolved,
|
|
@@ -15824,6 +17250,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15824
17250
|
!openPolicy.databasePath &&
|
|
15825
17251
|
!process.env.VITEST &&
|
|
15826
17252
|
process.env.NODE_ENV !== "test") {
|
|
17253
|
+
if (mayWriteToRepository(resolved))
|
|
17254
|
+
await excludeStateDirectoryLocally(resolved);
|
|
15827
17255
|
const progress = createIndexProgressReporter(options?.onProgress);
|
|
15828
17256
|
// Look for a build a previous run left unfinished before starting a
|
|
15829
17257
|
// new one. A candidate survives only when the process was killed —
|
|
@@ -15857,6 +17285,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15857
17285
|
throw new Error(`clean-index candidate audit failed: ${audit.issues.join(", ")}`);
|
|
15858
17286
|
await engine.promoteCandidate(candidate);
|
|
15859
17287
|
promoted = true;
|
|
17288
|
+
storageCommitted = true;
|
|
15860
17289
|
const activeDb = await openDb(resolved, {
|
|
15861
17290
|
skipContentReconciliation: true,
|
|
15862
17291
|
skipFreshnessGuard: true,
|
|
@@ -15884,6 +17313,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15884
17313
|
status: health.status,
|
|
15885
17314
|
issueCount,
|
|
15886
17315
|
missing: health.missing,
|
|
17316
|
+
repairSteps: health.repairSteps,
|
|
15887
17317
|
verifiedAt: health.verification.verifiedAt,
|
|
15888
17318
|
},
|
|
15889
17319
|
};
|
|
@@ -15902,6 +17332,9 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15902
17332
|
const watcherStateBefore = watcherStates.get(resolved)?.watcher;
|
|
15903
17333
|
if (!openPolicy.databasePath)
|
|
15904
17334
|
await suspendFileWatcher(resolved);
|
|
17335
|
+
let provisionalScopedEpoch;
|
|
17336
|
+
let scopedEpochCompleted = false;
|
|
17337
|
+
let scopedProofDb;
|
|
15905
17338
|
try {
|
|
15906
17339
|
invalidateResolutionCaches(repoPath);
|
|
15907
17340
|
const indexed = [];
|
|
@@ -15919,7 +17352,13 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15919
17352
|
skipContentReconciliation: true,
|
|
15920
17353
|
skipFreshnessGuard: true,
|
|
15921
17354
|
});
|
|
17355
|
+
if (newStore && !isMirror(resolved))
|
|
17356
|
+
db.run("INSERT OR REPLACE INTO meta(key, value) VALUES (?, ?)", [
|
|
17357
|
+
"storagePolicyInitializationPendingV1",
|
|
17358
|
+
"1",
|
|
17359
|
+
]);
|
|
15922
17360
|
const existingCoverage = coverageForDatabase(db);
|
|
17361
|
+
scopedProofDb = db;
|
|
15923
17362
|
const freshCoverage = existingCoverage.origin === "legacy" &&
|
|
15924
17363
|
!getMeta(db, "lastSuccessfulReconciliation") &&
|
|
15925
17364
|
(db.query("SELECT COUNT(*) count FROM index_state").get()
|
|
@@ -15942,7 +17381,13 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15942
17381
|
throw new Error(`knodin index --under: no indexable files under ${scopedUnder}`);
|
|
15943
17382
|
}
|
|
15944
17383
|
let completionMessage = "Local graph is current";
|
|
17384
|
+
let firstTargetedContentStart;
|
|
15945
17385
|
if (files && files.length > 0) {
|
|
17386
|
+
// A first exact/prefix index reads the whole retained scope without
|
|
17387
|
+
// going through indexRepo. Existing graphs, including widenings,
|
|
17388
|
+
// must retain their old epoch: untouched rows were not reverified.
|
|
17389
|
+
if (!db.query("SELECT 1 FROM index_state LIMIT 1").get())
|
|
17390
|
+
firstTargetedContentStart = new Date().toISOString();
|
|
15946
17391
|
progress("indexing-files", 0, `Checking ${files.length.toLocaleString()} requested files`, {
|
|
15947
17392
|
phaseTotal: files.length,
|
|
15948
17393
|
});
|
|
@@ -15954,8 +17399,30 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15954
17399
|
unchanged.push(relativePath);
|
|
15955
17400
|
}
|
|
15956
17401
|
else if (fs.existsSync(absPath)) {
|
|
17402
|
+
const initialStat = firstTargetedContentStart ? fs.statSync(absPath) : null;
|
|
17403
|
+
const initialHash = initialStat &&
|
|
17404
|
+
initialStat.ctimeMs >= Date.parse(firstTargetedContentStart ?? "") - 1
|
|
17405
|
+
? repairSourceFingerprint(repoPath, relativePath, Buffer.alloc(64 * 1024))
|
|
17406
|
+
: null;
|
|
15957
17407
|
await explicitPackageInputs.run(packageInputs, () => indexFile(absPath, relativePath, repoPath, db));
|
|
15958
17408
|
recordIndexState(db, repoPath, relativePath);
|
|
17409
|
+
if (initialStat) {
|
|
17410
|
+
const current = fs.statSync(absPath);
|
|
17411
|
+
if (current.ctimeMs !== initialStat.ctimeMs ||
|
|
17412
|
+
current.size !== initialStat.size ||
|
|
17413
|
+
current.mtimeMs !== initialStat.mtimeMs ||
|
|
17414
|
+
(initialHash !== null &&
|
|
17415
|
+
repairSourceFingerprint(repoPath, relativePath, Buffer.alloc(64 * 1024)) !==
|
|
17416
|
+
initialHash))
|
|
17417
|
+
throw new Error("Source changed during first scoped index; retry indexing.");
|
|
17418
|
+
if (initialHash !== null)
|
|
17419
|
+
setMeta(db, `${REPAIR_CHECKPOINT_PREFIX}${relativePath}`, JSON.stringify({
|
|
17420
|
+
version: 1,
|
|
17421
|
+
sourceHash: initialHash,
|
|
17422
|
+
mtimeMs: current.mtimeMs,
|
|
17423
|
+
size: current.size,
|
|
17424
|
+
}));
|
|
17425
|
+
}
|
|
15959
17426
|
}
|
|
15960
17427
|
else if (shouldPurgeMissingFile(db)) {
|
|
15961
17428
|
// File was deleted
|
|
@@ -16014,7 +17481,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
16014
17481
|
await reconcileIndex(repoPath, db, progress, options?.skipEmbeddings === true);
|
|
16015
17482
|
}
|
|
16016
17483
|
else {
|
|
16017
|
-
await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true);
|
|
17484
|
+
await indexRepo(repoPath, db, progress, options?.skipEmbeddings === true, true, false, clean);
|
|
16018
17485
|
}
|
|
16019
17486
|
// Reuse the same candidate set as indexing/reconciliation. This keeps
|
|
16020
17487
|
// LWC HTML/CSS bundle members visible in the response without admitting
|
|
@@ -16080,6 +17547,16 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
16080
17547
|
progress("verifying", 0, "Verifying local graph health", {
|
|
16081
17548
|
phaseTotal: 1,
|
|
16082
17549
|
});
|
|
17550
|
+
if (firstTargetedContentStart) {
|
|
17551
|
+
const epoch = validSourceEpoch(firstTargetedContentStart);
|
|
17552
|
+
if (epoch === null || !sourceSnapshotMatches(db, repoPath, epoch))
|
|
17553
|
+
throw new Error("Source changed during first scoped index; retry indexing.");
|
|
17554
|
+
// This is a genuinely empty graph's first complete retained
|
|
17555
|
+
// scope. Establish its proof before the ordinary deep audit;
|
|
17556
|
+
// legacy nonempty graphs never enter this path.
|
|
17557
|
+
setMeta(db, CONTENT_PROOF_EPOCH_KEY, firstTargetedContentStart);
|
|
17558
|
+
provisionalScopedEpoch = firstTargetedContentStart;
|
|
17559
|
+
}
|
|
16083
17560
|
let health = await engine.status(repoPath, {
|
|
16084
17561
|
audit: "deep",
|
|
16085
17562
|
ignoreActiveOperation: true,
|
|
@@ -16144,6 +17621,9 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
16144
17621
|
// the tally on every commit would make the signal meaningless noise.
|
|
16145
17622
|
if (scopedUnder !== undefined)
|
|
16146
17623
|
setMeta(db, COVERAGE_TALLY_IN_PROGRESS_META_KEY, "1");
|
|
17624
|
+
scopedEpochCompleted =
|
|
17625
|
+
health.status === "healthy" && coverageForDatabase(db).intentState === "complete";
|
|
17626
|
+
storageCommitted = !openPolicy.databasePath && health.status === "healthy";
|
|
16147
17627
|
return {
|
|
16148
17628
|
indexed,
|
|
16149
17629
|
unchanged,
|
|
@@ -16159,11 +17639,19 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
16159
17639
|
status: health.status,
|
|
16160
17640
|
issueCount,
|
|
16161
17641
|
missing: health.missing,
|
|
17642
|
+
repairSteps: health.repairSteps,
|
|
16162
17643
|
verifiedAt: health.verification.verifiedAt,
|
|
16163
17644
|
},
|
|
16164
17645
|
};
|
|
16165
17646
|
}
|
|
16166
17647
|
finally {
|
|
17648
|
+
if (provisionalScopedEpoch && !scopedEpochCompleted) {
|
|
17649
|
+
const proofDb = scopedProofDb;
|
|
17650
|
+
if (proofDb && getMeta(proofDb, CONTENT_PROOF_EPOCH_KEY) === provisionalScopedEpoch)
|
|
17651
|
+
proofDb.run("DELETE FROM meta WHERE key=?", [CONTENT_PROOF_EPOCH_KEY]);
|
|
17652
|
+
freshnessProbes.delete(resolved);
|
|
17653
|
+
statusCache.delete(databaseCacheKey(resolved, openPolicy.databasePath));
|
|
17654
|
+
}
|
|
16167
17655
|
const activeDb = dbInstances.get(resolved);
|
|
16168
17656
|
if (!openPolicy.databasePath &&
|
|
16169
17657
|
activeDb &&
|
|
@@ -16175,6 +17663,8 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
16175
17663
|
}
|
|
16176
17664
|
finally {
|
|
16177
17665
|
indexCoordination.release();
|
|
17666
|
+
if (storageCommitted)
|
|
17667
|
+
completeStorageMaintenance(resolved);
|
|
16178
17668
|
}
|
|
16179
17669
|
},
|
|
16180
17670
|
async searchText(term, repoPath, options = {}) {
|
|
@@ -18152,6 +19642,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
18152
19642
|
})));
|
|
18153
19643
|
}
|
|
18154
19644
|
case "dead_code": {
|
|
19645
|
+
const rootChains = apexDeadChains(db, isTestFilePath);
|
|
18155
19646
|
// Importer/export/test-aware: a function/class is only flagged dead when
|
|
18156
19647
|
// (1) its name appears in NO reference anywhere — references from test
|
|
18157
19648
|
// files already appear here, so "referenced" subsumes "used by a test",
|
|
@@ -18244,9 +19735,10 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
18244
19735
|
.query("PRAGMA table_info(symbols)")
|
|
18245
19736
|
.all()
|
|
18246
19737
|
.some((column) => column.name === "apexDeclaration");
|
|
18247
|
-
const stmt = db.query(`SELECT id, identity, name, kind, filePath, startLine, ${hasApexDeclarations ? "apexDeclaration" : "NULL AS apexDeclaration"} FROM symbols WHERE kind IN ('function', 'class', 'method')`);
|
|
19738
|
+
const stmt = db.query(`SELECT id, identity, name, kind, filePath, startLine, startCol, endLine, endCol, ${hasApexDeclarations ? "apexDeclaration" : "NULL AS apexDeclaration"} FROM symbols WHERE kind IN ('function', 'class', 'method')`);
|
|
18248
19739
|
let rows = stmt.all();
|
|
18249
19740
|
stmt.finalize();
|
|
19741
|
+
const hasApexChainScope = rows.some((row) => /\.cls$/i.test(row.filePath));
|
|
18250
19742
|
if (target)
|
|
18251
19743
|
rows = rows.filter((row) => row.filePath.includes(target));
|
|
18252
19744
|
const declarations = [];
|
|
@@ -18262,7 +19754,10 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
18262
19754
|
}
|
|
18263
19755
|
const protectedApexEntries = apexOrgEntryPoints(declarations);
|
|
18264
19756
|
const capability = apexReceiverCapability(db);
|
|
19757
|
+
const platformEntries = await queryPlatformEntries(db, repoPath, rows.filter((row) => protectedApexEntries.has(String(row.id))), cap, unsupportedApexFiles.size > 0 || capability.partial);
|
|
18265
19758
|
rows = rows.filter((r) => {
|
|
19759
|
+
if (/\.cls$/i.test(r.filePath) && r.identity && rootChains.protected.has(r.identity))
|
|
19760
|
+
return false;
|
|
18266
19761
|
if (protectedApexEntries.has(String(r.id)) || unsupportedApexFiles.has(r.filePath))
|
|
18267
19762
|
return false;
|
|
18268
19763
|
if (r.kind === "variable")
|
|
@@ -18271,7 +19766,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
18271
19766
|
return false;
|
|
18272
19767
|
if (ENTRY.has(r.name) || DUNDER_RE.test(r.name))
|
|
18273
19768
|
return false;
|
|
18274
|
-
if (referenced.has(r.name))
|
|
19769
|
+
if (referenced.has(r.name) && !(r.identity && rootChains.candidates.has(r.identity)))
|
|
18275
19770
|
return false;
|
|
18276
19771
|
if (isTestFilePath(r.filePath))
|
|
18277
19772
|
return false;
|
|
@@ -18297,14 +19792,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
18297
19792
|
});
|
|
18298
19793
|
if (target)
|
|
18299
19794
|
rows = rows.filter((r) => r.filePath.includes(target));
|
|
19795
|
+
const returnedChains = new Set(rows.flatMap((row) => row.identity && rootChains.candidates.has(row.identity)
|
|
19796
|
+
? [rootChains.candidates.get(row.identity)]
|
|
19797
|
+
: []));
|
|
19798
|
+
rootChains.analysis.chains = rootChains.analysis.chains.filter((chain) => returnedChains.has(chain.identity));
|
|
18300
19799
|
return finish(rows.map((r) => ({
|
|
19800
|
+
...(r.identity && rootChains.candidates.has(r.identity)
|
|
19801
|
+
? {
|
|
19802
|
+
candidateReason: "unrooted-chain",
|
|
19803
|
+
chainIdentity: rootChains.candidates.get(r.identity),
|
|
19804
|
+
}
|
|
19805
|
+
: {}),
|
|
18301
19806
|
symbol: r.name,
|
|
18302
19807
|
kind: r.kind,
|
|
18303
19808
|
file: r.filePath,
|
|
18304
19809
|
line: r.startLine,
|
|
18305
19810
|
heuristic: true,
|
|
18306
19811
|
identity: r.identity ?? undefined,
|
|
18307
|
-
sourceEvidence:
|
|
19812
|
+
sourceEvidence: r.identity && rootChains.candidates.has(r.identity)
|
|
19813
|
+
? "No modeled root reaches this indexed chain; excluded callers or org configuration may reach it. This is not permission to delete source."
|
|
19814
|
+
: "No statically resolved references; runtime dispatch, reflection, aliases, and indirect exports remain unresolved.",
|
|
18308
19815
|
drillDown: {
|
|
18309
19816
|
operation: "query",
|
|
18310
19817
|
pattern: "callers_of",
|
|
@@ -18314,13 +19821,16 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
18314
19821
|
},
|
|
18315
19822
|
})), {
|
|
18316
19823
|
deadCodeAnalysis: {
|
|
19824
|
+
platformEntries,
|
|
19825
|
+
...(hasApexChainScope ? { rootReachability: rootChains.analysis } : {}),
|
|
18317
19826
|
protectedApexEntries: protectedApexEntries.size,
|
|
18318
19827
|
unsupportedApexFiles: unsupportedApexFiles.size,
|
|
18319
19828
|
repositoryMissingApexCapabilityFiles: capability.missingFiles,
|
|
19829
|
+
partialScope: "apex-platform-declarations",
|
|
18320
19830
|
partial: unsupportedApexFiles.size > 0 || capability.partial,
|
|
18321
19831
|
limitations: [
|
|
18322
19832
|
"Org-facing Apex declarations are excluded; this does not prove runtime use or root reachability. Global visibility alone is not an exemption.",
|
|
18323
|
-
"Remaining results are static candidates, not safe-to-delete findings;
|
|
19833
|
+
"Remaining results are static candidates, not safe-to-delete findings; chain results describe only modeled indexed-source roots, not runtime nonuse.",
|
|
18324
19834
|
...(unsupportedApexFiles.size > 0 || capability.partial
|
|
18325
19835
|
? [capability.repair]
|
|
18326
19836
|
: []),
|