knodin 0.12.2 → 0.13.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/README.md +16 -1
- package/dist/bin/cli.js +173 -14
- package/dist/src/agent-events.js +25 -7
- package/dist/src/authenticated-cursor.js +81 -0
- package/dist/src/class-consumer-contract.js +18 -0
- package/dist/src/class-consumer-cursor.js +91 -0
- package/dist/src/class-consumer-delivery.js +22 -0
- package/dist/src/class-consumer-page.js +148 -0
- package/dist/src/cli-model.js +9 -3
- package/dist/src/docs-sections.js +1 -0
- package/dist/src/engine/apex-class-uses.js +430 -0
- package/dist/src/engine/apex-entry-points.js +98 -0
- package/dist/src/engine/apex-receiver.js +301 -0
- package/dist/src/engine/embedding-reuse.js +57 -0
- package/dist/src/engine/embeddings.js +22 -0
- package/dist/src/engine/index-coverage.js +215 -0
- package/dist/src/engine/index.js +2495 -252
- package/dist/src/engine/salesforce-components.js +460 -0
- package/dist/src/engine/seal.js +3 -0
- package/dist/src/engine/sqlite.js +44 -0
- package/dist/src/evidence-bundle.js +283 -0
- package/dist/src/evidence-graph.js +163 -0
- package/dist/src/failure-diagnosis.js +80 -5
- package/dist/src/file-dependency.js +35 -0
- package/dist/src/graph-query-health.js +47 -1
- package/dist/src/implementation-search.js +69 -0
- package/dist/src/index-coverage-read.js +33 -0
- package/dist/src/investigation.js +195 -0
- package/dist/src/mcp-reliability.js +4 -0
- package/dist/src/mcp-worker-supervisor.js +122 -6
- package/dist/src/progressive-evidence.js +4 -4
- package/dist/src/response-budget.js +129 -3
- package/dist/src/server.js +18 -5
- package/dist/src/shared-index/publisher.js +41 -1
- package/dist/src/tools/knodin-tools.js +224 -41
- package/docs/CLI.md +92 -0
- package/docs/MCP.md +67 -0
- package/docs/PROGRESSIVE-EVIDENCE.md +62 -0
- package/docs/SALESFORCE-BINDINGS.md +121 -0
- package/docs/SALESFORCE-DEAD-CODE.md +45 -0
- package/docs/SCOPED-INDEXING.md +76 -0
- package/docs/apex-receiver-resolution.md +41 -0
- package/docs/releases/0.13.0.md +62 -0
- package/docs/structural-only-indexing.md +20 -0
- package/package.json +11 -5
package/README.md
CHANGED
|
@@ -150,7 +150,7 @@ Start broad, inspect the likely change surface, then review the actual diff:
|
|
|
150
150
|
|
|
151
151
|
```bash
|
|
152
152
|
# Orient on an unfamiliar checkout
|
|
153
|
-
knodin context
|
|
153
|
+
knodin context "understand this checkout"
|
|
154
154
|
|
|
155
155
|
# Explain a symbol with source and relationships
|
|
156
156
|
knodin explain createServer
|
|
@@ -170,6 +170,21 @@ knodin map
|
|
|
170
170
|
knodin pack createServer --max-tokens 4000
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
+
For a task that needs an implementation and its dependencies:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
knodin context "find code that persists an order" --implementations --json
|
|
177
|
+
knodin context "rename persist" --symbol persist --file src/storage.ts --json
|
|
178
|
+
knodin evidence expand --files src/storage.ts --items 100 --json
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Discovery returns candidates for you to select; investigation collects source,
|
|
182
|
+
callers, importers, tests, and impact for the selected identity. Evidence bundles
|
|
183
|
+
page exact source and issue reusable per-file receipts after complete delivery.
|
|
184
|
+
Ambiguity, incomplete coverage, and budget limits stay visible. See the
|
|
185
|
+
[CLI guide](docs/CLI.md#task-discovery-and-investigation) and
|
|
186
|
+
[evidence protocol](docs/PROGRESSIVE-EVIDENCE.md#graph-backed-file-bundles).
|
|
187
|
+
|
|
173
188
|
If freshness or lifecycle checks fail:
|
|
174
189
|
|
|
175
190
|
```bash
|
package/dist/bin/cli.js
CHANGED
|
@@ -20,6 +20,10 @@ import { inspectClaudeAgentHooks, installClaudeAgentHooks, uninstallClaudeAgentH
|
|
|
20
20
|
import { detectSupportedAgents, parseInitScope, } from "../src/agent-integration.js";
|
|
21
21
|
import { refreshExternalGraphArtifacts, writeArtifactRefreshRecord, } from "../src/artifact-refresh.js";
|
|
22
22
|
import { formatBackupHuman, installBackupRetention, listBackups, maybeRunOpportunisticRetention, pruneBackups, removeBackupRetention, retentionDoctor, retentionStatus, runInstalledRetention, } from "../src/backup-retention.js";
|
|
23
|
+
import { ClassConsumerQueryError, } from "../src/class-consumer-contract.js";
|
|
24
|
+
import { readClassConsumerCursor } from "../src/class-consumer-cursor.js";
|
|
25
|
+
import { validateClassConsumerDelivery } from "../src/class-consumer-delivery.js";
|
|
26
|
+
import { finalizeClassConsumerPage } from "../src/class-consumer-page.js";
|
|
23
27
|
import { checkIndexed, extractPositionals, extractRepoFlag, parseReviewArgs, planIndex, resolveCliRuntimeCommand, resolveRepo, } from "../src/cli-args.js";
|
|
24
28
|
import { helpCommandPath, parseCliInvocation, renderCliHelp } from "../src/cli-model.js";
|
|
25
29
|
import { buildKnodinContext } from "../src/context.js";
|
|
@@ -31,19 +35,24 @@ import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PA
|
|
|
31
35
|
import { runSeal } from "../src/engine/seal-command.js";
|
|
32
36
|
import { runSealedQuery } from "../src/engine/sealed-query.js";
|
|
33
37
|
import { resolveDbPath } from "../src/engine/state-paths.js";
|
|
38
|
+
import { deliverEvidenceBundle } from "../src/evidence-bundle.js";
|
|
39
|
+
import { createEvidenceGraphAdapter } from "../src/evidence-graph.js";
|
|
34
40
|
import { diagnoseFailure, } from "../src/failure-diagnosis.js";
|
|
35
41
|
import { gitExecutable } from "../src/git-executable.js";
|
|
36
42
|
import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../src/graph-query-health.js";
|
|
43
|
+
import { findImplementationCandidates } from "../src/implementation-search.js";
|
|
37
44
|
import { createIndexActivityReporter } from "../src/index-activity.js";
|
|
45
|
+
import { indexCoverageCore, indexCoverageNotice, indexCoverageSnapshot, } from "../src/index-coverage-read.js";
|
|
38
46
|
import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, repairLifecycleRouting, } from "../src/init.js";
|
|
39
47
|
import { createInitProgressRenderer } from "../src/init-progress.js";
|
|
48
|
+
import { buildTaskInvestigation } from "../src/investigation.js";
|
|
40
49
|
import { attachLifecycleHealth, attachRepairLifecycle } from "../src/lifecycle-health.js";
|
|
41
50
|
import { acknowledgeUpdateFailure, queryOwnerAvailability, readManagerUpdateState, readUpdateJournal, resolveManagerOwnership, setManualPin, unpinUpdate, updateAttention, writeManagerUpdateState, } from "../src/manager-update.js";
|
|
42
51
|
import { addMirror, listMirrors, refreshMirror, removeMirror } from "../src/mirror.js";
|
|
43
52
|
import { compressOutput, compressOutputFile, deleteOutputArtifact, readOutputArtifact, } from "../src/output-compression.js";
|
|
44
53
|
import { clearTelemetry, exportTelemetry, readTelemetryRecords, telemetryStatus, writeTelemetryReport, } from "../src/output-telemetry.js";
|
|
45
54
|
import { auditPullRequests } from "../src/pr-triage.js";
|
|
46
|
-
import { deliverProgressiveEvidence, } from "../src/progressive-evidence.js";
|
|
55
|
+
import { deliverProgressiveEvidence, evidenceHandleSecret, } from "../src/progressive-evidence.js";
|
|
47
56
|
import { acquireRepairLease } from "../src/repair-lease.js";
|
|
48
57
|
import { createRepairPlan, createRepairProgressRenderer, parseRepairCliArgs, resolveRepairProgressMode, serializeRepairJsonlRecord, } from "../src/repair-progress.js";
|
|
49
58
|
import { runRepositoryInitializationProcess } from "../src/repository-init-process.js";
|
|
@@ -161,15 +170,21 @@ function formatSemanticGap(readiness) {
|
|
|
161
170
|
}
|
|
162
171
|
function formatIndexHuman(result) {
|
|
163
172
|
const semantic = formatSemanticGap(result.semanticReadiness);
|
|
173
|
+
const scope = formatIndexCoverageHuman(result.indexCoverage);
|
|
164
174
|
if (result.indexed.length === 0 && result.unchanged.length > 0) {
|
|
165
175
|
const noun = result.unchanged.length === 1 ? "file" : "files";
|
|
166
|
-
return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n`;
|
|
176
|
+
return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n${scope}\n`;
|
|
167
177
|
}
|
|
168
178
|
const unchanged = result.unchanged.length > 0
|
|
169
179
|
? `; ${result.unchanged.length.toLocaleString()} already current`
|
|
170
180
|
: "";
|
|
171
181
|
const noun = result.indexed.length === 1 ? "file" : "files";
|
|
172
|
-
return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n`;
|
|
182
|
+
return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n${scope}\n`;
|
|
183
|
+
}
|
|
184
|
+
function formatIndexCoverageHuman(coverage) {
|
|
185
|
+
if (!coverage)
|
|
186
|
+
return "Local graph index coverage is unknown; a negative graph answer does not establish absence.";
|
|
187
|
+
return `Local graph index coverage: ${coverage.mode}; intent ${coverage.intentState}; repository inventory complete: ${coverage.repositoryComplete}. ${indexCoverageNotice(coverage) ?? "Inventory extent does not establish complete static relationships or runtime use."}`;
|
|
173
188
|
}
|
|
174
189
|
function formatIndexVerificationError(result) {
|
|
175
190
|
const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
|
|
@@ -414,7 +429,7 @@ function formatStatusHuman(result) {
|
|
|
414
429
|
const indexedCounts = result.coverage.countsUnknown
|
|
415
430
|
? "indexed and symbol counts unknown; the graph could not be read"
|
|
416
431
|
: `${result.coverage.indexedFiles} indexed files, ${result.coverage.filesWithSymbols} files with symbols`;
|
|
417
|
-
const coverage = `${result.coverage.sourceFiles} source files, ${indexedCounts}${formatCoverageGaps(result.coverage.skipped)}${mirrorNote}${semanticNote}`;
|
|
432
|
+
const coverage = `${result.coverage.sourceFiles} source files, ${indexedCounts}${formatCoverageGaps(result.coverage.skipped)}${mirrorNote}${semanticNote}. ${formatIndexCoverageHuman(result.indexCoverage)}`;
|
|
418
433
|
if (result.status === "indexing" && result.activity) {
|
|
419
434
|
const count = result.activity.phaseTotal === undefined
|
|
420
435
|
? ""
|
|
@@ -1537,6 +1552,8 @@ async function main() {
|
|
|
1537
1552
|
return attemptOpportunisticSharedRestore(repo, engine);
|
|
1538
1553
|
};
|
|
1539
1554
|
let result;
|
|
1555
|
+
let finalizedClassConsumer = false;
|
|
1556
|
+
let classConsumerErrorContract;
|
|
1540
1557
|
let repairOutput;
|
|
1541
1558
|
let repairWasPlan = false;
|
|
1542
1559
|
let repairWasLifecycle = false;
|
|
@@ -1555,7 +1572,7 @@ async function main() {
|
|
|
1555
1572
|
process.exitCode = 1;
|
|
1556
1573
|
return verified;
|
|
1557
1574
|
}
|
|
1558
|
-
return decorateGraphQueryResult(value, verified.state, verified.graph.freshness);
|
|
1575
|
+
return decorateGraphQueryResult(value, verified.state, verified.graph.freshness, verified.graph.indexCoverage, health.graph.indexCoverage);
|
|
1559
1576
|
};
|
|
1560
1577
|
switch (cmd) {
|
|
1561
1578
|
case "shared": {
|
|
@@ -1830,10 +1847,11 @@ async function main() {
|
|
|
1830
1847
|
}
|
|
1831
1848
|
const freshness = status.freshness?.state ?? "unknown";
|
|
1832
1849
|
const cacheKey = JSON.stringify({
|
|
1833
|
-
schemaVersion:
|
|
1850
|
+
schemaVersion: 2,
|
|
1834
1851
|
head: status.freshness?.currentHead,
|
|
1835
1852
|
fingerprint: status.freshness?.workingTree?.indexedFingerprint,
|
|
1836
1853
|
generation: status.indexGeneration,
|
|
1854
|
+
indexCoverage: indexCoverageCore(status.indexCoverage),
|
|
1837
1855
|
budgets: [600, 8192, 12],
|
|
1838
1856
|
});
|
|
1839
1857
|
const cacheEligible = canUseSessionContextCache(status.status, freshness);
|
|
@@ -1848,7 +1866,11 @@ async function main() {
|
|
|
1848
1866
|
cacheHit = false;
|
|
1849
1867
|
try {
|
|
1850
1868
|
const context = await buildKnodinContext(engine, "Orient this coding session", repo, undefined, []);
|
|
1851
|
-
|
|
1869
|
+
const verified = await engine.status(repo, { audit: "cached" });
|
|
1870
|
+
if (indexCoverageSnapshot(status.indexCoverage) !==
|
|
1871
|
+
indexCoverageSnapshot(verified.indexCoverage))
|
|
1872
|
+
throw new Error("Index coverage changed during session context generation");
|
|
1873
|
+
text = renderSessionContext(context, freshness, verified.indexCoverage);
|
|
1852
1874
|
writeSessionContextCache(repo, cacheKey, text);
|
|
1853
1875
|
}
|
|
1854
1876
|
catch {
|
|
@@ -2687,6 +2709,24 @@ async function main() {
|
|
|
2687
2709
|
case "evidence": {
|
|
2688
2710
|
const level = rest[0];
|
|
2689
2711
|
const file = rest[1];
|
|
2712
|
+
const files = selectorValue("--files")?.split(",");
|
|
2713
|
+
if (files) {
|
|
2714
|
+
if (level !== "evidence" && level !== "expand")
|
|
2715
|
+
throw new Error("Evidence bundles require evidence or expand level");
|
|
2716
|
+
if (file && !file.startsWith("--"))
|
|
2717
|
+
throw new Error("Choose a file or --files, not both");
|
|
2718
|
+
result = await deliverEvidenceBundle({
|
|
2719
|
+
repo,
|
|
2720
|
+
files,
|
|
2721
|
+
level,
|
|
2722
|
+
continuation: selectorValue("--continuation"),
|
|
2723
|
+
alreadyPresent: selectorValue("--already-present")?.split(","),
|
|
2724
|
+
byteLimit: responseBudget.bytes,
|
|
2725
|
+
tokenLimit: responseBudget.tokens,
|
|
2726
|
+
itemLimit: responseBudget.items,
|
|
2727
|
+
}, createEvidenceGraphAdapter(engine));
|
|
2728
|
+
break;
|
|
2729
|
+
}
|
|
2690
2730
|
if (!file || !["locate", "outline", "evidence", "expand"].includes(level))
|
|
2691
2731
|
throw new Error("knodin evidence requires locate|outline|evidence|expand <file>");
|
|
2692
2732
|
result = deliverProgressiveEvidence({
|
|
@@ -2717,7 +2757,7 @@ async function main() {
|
|
|
2717
2757
|
? ""
|
|
2718
2758
|
: (rest[1] ?? "");
|
|
2719
2759
|
if (!pattern) {
|
|
2720
|
-
process.stderr.write("knodin query requires a <pattern> (lsp_diagnostics|lsp_definitions|lsp_declarations|lsp_implementations|callers_of|callees_of|imports_of|importers_of|import_cycles|file_summary|file_metrics|batch_outline|project_overview|shortest_path|cross_substrate_path|inheritors_of|structural_implementations_of|tests_for|impact|dead_code|large_functions|large_files|rename_preview|flows|flow_of|stats|traverse|feature_path|flow_analysis|resource_reachability|knowledge_gaps|surprising_connections|suggested_questions|architecture_overview|community|triggers_of|publishers_of|listeners_of|handlers_of|endpoints_for|consumers_of|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
|
|
2760
|
+
process.stderr.write("knodin query requires a <pattern> (lsp_diagnostics|lsp_definitions|lsp_declarations|lsp_implementations|callers_of|callees_of|imports_of|importers_of|import_cycles|file_summary|file_metrics|batch_outline|project_overview|shortest_path|cross_substrate_path|inheritors_of|structural_implementations_of|tests_for|impact|dead_code|large_functions|large_files|rename_preview|flows|flow_of|stats|traverse|feature_path|flow_analysis|resource_reachability|knowledge_gaps|surprising_connections|suggested_questions|architecture_overview|community|triggers_of|publishers_of|listeners_of|handlers_of|endpoints_for|consumers_of|class_consumers|children_of|federated_repos|mcp_tools|api_contract_mismatches)\n");
|
|
2721
2761
|
process.exit(1);
|
|
2722
2762
|
}
|
|
2723
2763
|
const directionValue = selectorValue("--direction");
|
|
@@ -2788,6 +2828,82 @@ async function main() {
|
|
|
2788
2828
|
process.exitCode = 1;
|
|
2789
2829
|
break;
|
|
2790
2830
|
}
|
|
2831
|
+
if (pattern === "class_consumers") {
|
|
2832
|
+
if (queryLimitRaw && Number(queryLimitRaw) > 1000)
|
|
2833
|
+
throw new Error("class_consumers --limit must not exceed 1000 sites");
|
|
2834
|
+
if (["--offset", "--kinds", "--relations", "--direction", "--depth", "--exclude-tests"].some((flag) => rest.includes(flag)))
|
|
2835
|
+
throw new Error("class_consumers supports --test-scope/--path and --continuation, not offset/kinds/relations/direction/depth/exclude-tests");
|
|
2836
|
+
const secret = evidenceHandleSecret(repo).secret;
|
|
2837
|
+
const token = selectorValue("--continuation");
|
|
2838
|
+
const cursor = token ? readClassConsumerCursor(token, secret) : undefined;
|
|
2839
|
+
const request = {
|
|
2840
|
+
symbol: target,
|
|
2841
|
+
selector,
|
|
2842
|
+
testScope: (testScopeValue ?? "all"),
|
|
2843
|
+
path: selectorValue("--path"),
|
|
2844
|
+
limit: queryLimitRaw ? Number(queryLimitRaw) : 100,
|
|
2845
|
+
...(cursor
|
|
2846
|
+
? {
|
|
2847
|
+
after: cursor.after,
|
|
2848
|
+
expectedQueryDigest: cursor.queryDigest,
|
|
2849
|
+
expectedSnapshotDigest: cursor.snapshotDigest,
|
|
2850
|
+
}
|
|
2851
|
+
: {}),
|
|
2852
|
+
};
|
|
2853
|
+
try {
|
|
2854
|
+
const page = await engine.queryClassConsumers(repo, request);
|
|
2855
|
+
// Defer maintenance, not the audit, until a later eligible status or close.
|
|
2856
|
+
const verified = await inspectGraphQueryHealth(repo, async (target) => attachLifecycleHealth(target, await engine.status(target, { audit: "cached", checkpoint: "defer" })));
|
|
2857
|
+
if (!verified.available) {
|
|
2858
|
+
result = verified;
|
|
2859
|
+
process.exitCode = 1;
|
|
2860
|
+
break;
|
|
2861
|
+
}
|
|
2862
|
+
await validateClassConsumerDelivery(page, request, (next) => engine.queryClassConsumers(repo, next));
|
|
2863
|
+
result = finalizeClassConsumerPage(page, {
|
|
2864
|
+
secret,
|
|
2865
|
+
budget: responseBudget,
|
|
2866
|
+
decorate: (candidate) => decorateGraphQueryResult(candidate, verified.state, undefined, verified.graph.indexCoverage, queryHealth?.available ? queryHealth.graph.indexCoverage : undefined),
|
|
2867
|
+
});
|
|
2868
|
+
finalizedClassConsumer = true;
|
|
2869
|
+
}
|
|
2870
|
+
catch (error) {
|
|
2871
|
+
if (error instanceof ClassConsumerQueryError &&
|
|
2872
|
+
[
|
|
2873
|
+
"CLASS_CONSUMER_TARGET_NOT_FOUND",
|
|
2874
|
+
"CLASS_CONSUMER_TARGET_AMBIGUOUS",
|
|
2875
|
+
"CLASS_CONSUMER_TARGET_CAPABILITY_MISSING",
|
|
2876
|
+
].includes(error.code)) {
|
|
2877
|
+
result = {
|
|
2878
|
+
available: false,
|
|
2879
|
+
status: "unavailable",
|
|
2880
|
+
pattern,
|
|
2881
|
+
targetResolution: error.code === "CLASS_CONSUMER_TARGET_CAPABILITY_MISSING"
|
|
2882
|
+
? "capability-missing"
|
|
2883
|
+
: error.code === "CLASS_CONSUMER_TARGET_NOT_FOUND"
|
|
2884
|
+
? "not-found"
|
|
2885
|
+
: "ambiguous",
|
|
2886
|
+
code: error.code,
|
|
2887
|
+
error: error.message,
|
|
2888
|
+
candidates: error.candidates,
|
|
2889
|
+
...(queryHealth?.available
|
|
2890
|
+
? {
|
|
2891
|
+
indexCoverage: queryHealth.graph.indexCoverage,
|
|
2892
|
+
freshness: queryHealth.graph.freshness,
|
|
2893
|
+
}
|
|
2894
|
+
: {}),
|
|
2895
|
+
};
|
|
2896
|
+
classConsumerErrorContract = {
|
|
2897
|
+
code: error.code,
|
|
2898
|
+
targetResolution: result.targetResolution,
|
|
2899
|
+
};
|
|
2900
|
+
process.exitCode = 1;
|
|
2901
|
+
}
|
|
2902
|
+
else
|
|
2903
|
+
throw error;
|
|
2904
|
+
}
|
|
2905
|
+
break;
|
|
2906
|
+
}
|
|
2791
2907
|
result = await engine.query(pattern, target, repo, to, queryLimitRaw ? Number(queryLimitRaw) : undefined, depth, detailLevel, selector, pattern === "impact"
|
|
2792
2908
|
? {
|
|
2793
2909
|
mode: selectorValue("--impact-mode") === "file" ? "file" : "symbol",
|
|
@@ -2847,12 +2963,12 @@ async function main() {
|
|
|
2847
2963
|
process.exitCode = 1;
|
|
2848
2964
|
break;
|
|
2849
2965
|
}
|
|
2850
|
-
result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness);
|
|
2966
|
+
result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness, verifiedQueryHealth.graph.indexCoverage, queryHealth.graph.indexCoverage);
|
|
2851
2967
|
}
|
|
2852
2968
|
// A target that does not exist is operator error, not a negative
|
|
2853
2969
|
// answer, so it exits non-zero like an unavailable graph rather than
|
|
2854
2970
|
// like real dead code (KNODIN-28). `resolved` with zero rows keeps
|
|
2855
|
-
// exit 0:
|
|
2971
|
+
// exit 0: no callers were found within the reported index coverage.
|
|
2856
2972
|
if (result?.targetResolution === "not-found")
|
|
2857
2973
|
process.exitCode = 1;
|
|
2858
2974
|
break;
|
|
@@ -3034,7 +3150,26 @@ async function main() {
|
|
|
3034
3150
|
process.stderr.write('knodin context requires a "<task>" description\n');
|
|
3035
3151
|
process.exit(1);
|
|
3036
3152
|
}
|
|
3037
|
-
|
|
3153
|
+
const symbol = selectorValue("--symbol");
|
|
3154
|
+
if (rest.includes("--implementations")) {
|
|
3155
|
+
if (symbol)
|
|
3156
|
+
throw new Error("Choose --implementations or --symbol, not both");
|
|
3157
|
+
result = await graphRead(() => findImplementationCandidates(engine, repo, {
|
|
3158
|
+
task,
|
|
3159
|
+
limit: selectorValue("--limit") ? Number(selectorValue("--limit")) : undefined,
|
|
3160
|
+
offset: selectorValue("--offset") ? Number(selectorValue("--offset")) : undefined,
|
|
3161
|
+
}));
|
|
3162
|
+
break;
|
|
3163
|
+
}
|
|
3164
|
+
result = await graphRead(() => symbol
|
|
3165
|
+
? buildTaskInvestigation(engine, repo, {
|
|
3166
|
+
task,
|
|
3167
|
+
symbol,
|
|
3168
|
+
...selector,
|
|
3169
|
+
limit: selectorValue("--limit") ? Number(selectorValue("--limit")) : undefined,
|
|
3170
|
+
depth: selectorValue("--depth") ? Number(selectorValue("--depth")) : undefined,
|
|
3171
|
+
})
|
|
3172
|
+
: buildKnodinContext(engine, task, repo, rest[1]));
|
|
3038
3173
|
break;
|
|
3039
3174
|
}
|
|
3040
3175
|
default:
|
|
@@ -3042,6 +3177,8 @@ async function main() {
|
|
|
3042
3177
|
process.exit(1);
|
|
3043
3178
|
}
|
|
3044
3179
|
await engine.close();
|
|
3180
|
+
if (result?.available === false)
|
|
3181
|
+
process.exitCode = 1;
|
|
3045
3182
|
if (agentEventOutput) {
|
|
3046
3183
|
if (result !== null && result !== undefined)
|
|
3047
3184
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
@@ -3050,9 +3187,31 @@ async function main() {
|
|
|
3050
3187
|
if (statusWasWatched)
|
|
3051
3188
|
return;
|
|
3052
3189
|
const fileMetricsOutput = cmd === "query" && rest[0] === "file_metrics";
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3190
|
+
// The bundle protocol budgets its signed manifest, source and continuation together.
|
|
3191
|
+
// Generic post-truncation would invalidate that recoverable envelope.
|
|
3192
|
+
const evidenceBundle = cmd === "evidence" && selectorValue("--files") !== undefined;
|
|
3193
|
+
const boundedResult = evidenceBundle || finalizedClassConsumer
|
|
3194
|
+
? result
|
|
3195
|
+
: applyResponseBudget(result, cmd === "query" && rest[0] === "flow_analysis" ? "query:flow_analysis" : cmd, responseBudget, fileMetricsOutput
|
|
3196
|
+
? { bytes: 16_777_216, tokens: 4_194_304, items: 100_000 }
|
|
3197
|
+
: { bytes: 65_536, tokens: 16_384, items: 100 });
|
|
3198
|
+
if (classConsumerErrorContract) {
|
|
3199
|
+
const delivered = boundedResult;
|
|
3200
|
+
if (delivered.code !== classConsumerErrorContract.code ||
|
|
3201
|
+
delivered.targetResolution !== classConsumerErrorContract.targetResolution ||
|
|
3202
|
+
delivered.available !== false ||
|
|
3203
|
+
delivered.status !== "unavailable")
|
|
3204
|
+
throw new Error("Response budget cannot preserve the class-consumer error contract; increase the budget.");
|
|
3205
|
+
}
|
|
3206
|
+
if (cmd === "query" && rest[0] === "inheritors_of") {
|
|
3207
|
+
const qualification = result
|
|
3208
|
+
?.inheritanceQuery;
|
|
3209
|
+
const delivered = boundedResult
|
|
3210
|
+
?.inheritanceQuery;
|
|
3211
|
+
if (qualification &&
|
|
3212
|
+
["version", "mode", "partial", "truncated", "apex"].some((key) => delivered?.[key] !== qualification[key]))
|
|
3213
|
+
throw new Error("Response budget cannot preserve the inheritance qualification; increase the budget.");
|
|
3214
|
+
}
|
|
3056
3215
|
const finalExitCode = Math.max(Number(process.exitCode ?? 0), repairExitCode);
|
|
3057
3216
|
if (cmd === "init" && !jsonOutput) {
|
|
3058
3217
|
process.stdout.write(formatInitHuman(boundedResult));
|
package/dist/src/agent-events.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { indexCoverageCore, indexCoverageNotice } from "./index-coverage-read.js";
|
|
3
4
|
import { countOutputTokens } from "./output-telemetry.js";
|
|
4
5
|
import { appendSessionEvent, } from "./session-telemetry.js";
|
|
5
6
|
export const SESSION_CONTEXT_BUDGET_LINE = "Budget: at most 600 tokens, 8 KiB, 12 items.";
|
|
@@ -81,23 +82,40 @@ export function writeSessionContextCache(repo, key, text) {
|
|
|
81
82
|
export function canUseSessionContextCache(status, freshness) {
|
|
82
83
|
return status === "healthy" && freshness === "fresh";
|
|
83
84
|
}
|
|
84
|
-
export function renderSessionContext(context, freshness) {
|
|
85
|
+
export function renderSessionContext(context, freshness, coverage) {
|
|
85
86
|
const list = (values, render) => (values ?? []).slice(0, 3).map(render).join(", ") || "none reported";
|
|
86
|
-
|
|
87
|
+
const header = [
|
|
87
88
|
"## knodin session context",
|
|
88
89
|
`Graph evidence: ${freshness}. Treat stale or unavailable evidence as incomplete.`,
|
|
89
90
|
SESSION_CONTEXT_BUDGET_LINE,
|
|
90
|
-
`
|
|
91
|
+
`Index coverage: ${JSON.stringify(indexCoverageCore(coverage) ?? { mode: "unknown", repositoryComplete: false, negativeScope: "unknown" })}`,
|
|
92
|
+
indexCoverageNotice(coverage) ??
|
|
93
|
+
(coverage
|
|
94
|
+
? "Inventory extent does not prove complete relationships or runtime use."
|
|
95
|
+
: "Index coverage is unknown; negatives do not establish repository-wide absence."),
|
|
96
|
+
];
|
|
97
|
+
const details = [
|
|
98
|
+
`Indexed scope: ${context.stats?.files ?? "?"} files, ${context.stats?.symbols ?? "?"} symbols.`,
|
|
91
99
|
`Subsystems: ${list(context.communities, (value) => `${value.name} (${value.size})`)}`,
|
|
92
100
|
`Hubs: ${list(context.hubs, (value) => `${value.symbol} (${value.degree})`)}`,
|
|
93
101
|
`Flows: ${list(context.flows, (value) => value.symbol ?? "unnamed")}`,
|
|
94
102
|
`Suggested next operation: ${context.suggestedOperation ?? "context"} (heuristic only).`,
|
|
95
103
|
"Use exact source evidence and preserve ambiguity, freshness, omissions, and response budgets.",
|
|
96
|
-
]
|
|
97
|
-
|
|
98
|
-
|
|
104
|
+
];
|
|
105
|
+
let truncated = false;
|
|
106
|
+
for (;;) {
|
|
107
|
+
const text = [
|
|
108
|
+
...header,
|
|
109
|
+
...details,
|
|
110
|
+
...(truncated ? ["Context truncated to the configured session budget."] : []),
|
|
111
|
+
].join("\n");
|
|
112
|
+
if (countOutputTokens(text) <= 600 && Buffer.byteLength(text) <= 8192)
|
|
113
|
+
return text;
|
|
114
|
+
if (details.length === 0)
|
|
115
|
+
throw new RangeError("Session context budget cannot preserve index coverage");
|
|
116
|
+
details.pop();
|
|
117
|
+
truncated = true;
|
|
99
118
|
}
|
|
100
|
-
return text;
|
|
101
119
|
}
|
|
102
120
|
export function recordClaudeLifecycleEvent(repo, event, payload) {
|
|
103
121
|
const sessionId = payload.session_id;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export const MAX_CURSOR_PAYLOAD_BYTES = 4096;
|
|
3
|
+
export const MAX_AUTHENTICATED_CURSOR_LENGTH = 6144;
|
|
4
|
+
const DOMAIN = "knodin:authenticated-cursor:v1\0";
|
|
5
|
+
function checkParameters(secret, prefix) {
|
|
6
|
+
if (!Buffer.isBuffer(secret) || secret.length !== 32)
|
|
7
|
+
throw new TypeError("cursor signing key must be 32 bytes");
|
|
8
|
+
if (!/^[a-z][a-z0-9]{1,15}$/.test(prefix))
|
|
9
|
+
throw new TypeError("invalid cursor prefix");
|
|
10
|
+
}
|
|
11
|
+
function signature(secret, prefix, body) {
|
|
12
|
+
return createHmac("sha256", secret)
|
|
13
|
+
.update(DOMAIN)
|
|
14
|
+
.update(prefix)
|
|
15
|
+
.update("\0")
|
|
16
|
+
.update(body)
|
|
17
|
+
.digest();
|
|
18
|
+
}
|
|
19
|
+
function isRecord(value) {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
21
|
+
return false;
|
|
22
|
+
const prototype = Object.getPrototypeOf(value);
|
|
23
|
+
return prototype === Object.prototype || prototype === null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Authentication only, not encryption, schema validation or a freshness lease.
|
|
27
|
+
* Callers supply the existing per-repository evidence secret and a distinct
|
|
28
|
+
* protocol prefix; they must validate payload shape and current query/snapshot.
|
|
29
|
+
* Sign only the boundary of rows actually delivered after response budgeting.
|
|
30
|
+
*/
|
|
31
|
+
export function signAuthenticatedCursor(value, secret, prefix) {
|
|
32
|
+
checkParameters(secret, prefix);
|
|
33
|
+
let serialized;
|
|
34
|
+
try {
|
|
35
|
+
if (!isRecord(value))
|
|
36
|
+
throw new Error("cursor payload is not a record");
|
|
37
|
+
serialized = JSON.stringify(value);
|
|
38
|
+
if (typeof serialized !== "string" || !isRecord(JSON.parse(serialized)))
|
|
39
|
+
throw new Error("serialized cursor payload is not a record");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new TypeError("cursor payload must be a serializable JSON object");
|
|
43
|
+
}
|
|
44
|
+
if (Buffer.byteLength(serialized) > MAX_CURSOR_PAYLOAD_BYTES)
|
|
45
|
+
throw new RangeError("cursor payload exceeds its byte limit");
|
|
46
|
+
const body = Buffer.from(serialized).toString("base64url");
|
|
47
|
+
return `${prefix}.${body}.${signature(secret, prefix, body).toString("base64url")}`;
|
|
48
|
+
}
|
|
49
|
+
export function verifyAuthenticatedCursor(value, secret, prefix) {
|
|
50
|
+
checkParameters(secret, prefix);
|
|
51
|
+
const invalid = () => new Error("invalid authenticated cursor");
|
|
52
|
+
if (typeof value !== "string" || value.length > MAX_AUTHENTICATED_CURSOR_LENGTH)
|
|
53
|
+
throw invalid();
|
|
54
|
+
const parts = value.split(".");
|
|
55
|
+
if (parts.length !== 3 || parts[0] !== prefix)
|
|
56
|
+
throw invalid();
|
|
57
|
+
const [, body, encodedSignature] = parts;
|
|
58
|
+
if (!/^[A-Za-z0-9_-]+$/.test(body) || !/^[A-Za-z0-9_-]+$/.test(encodedSignature))
|
|
59
|
+
throw invalid();
|
|
60
|
+
const actual = Buffer.from(encodedSignature, "base64url");
|
|
61
|
+
const expected = signature(secret, prefix, body);
|
|
62
|
+
if (actual.length !== expected.length ||
|
|
63
|
+
actual.toString("base64url") !== encodedSignature ||
|
|
64
|
+
!timingSafeEqual(actual, expected))
|
|
65
|
+
throw invalid();
|
|
66
|
+
const bytes = Buffer.from(body, "base64url");
|
|
67
|
+
if (bytes.length > MAX_CURSOR_PAYLOAD_BYTES || bytes.toString("base64url") !== body)
|
|
68
|
+
throw invalid();
|
|
69
|
+
const text = bytes.toString("utf8");
|
|
70
|
+
if (!Buffer.from(text).equals(bytes))
|
|
71
|
+
throw invalid();
|
|
72
|
+
try {
|
|
73
|
+
const payload = JSON.parse(text);
|
|
74
|
+
if (isRecord(payload))
|
|
75
|
+
return payload;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Never echo untrusted cursor contents or decoder error details.
|
|
79
|
+
}
|
|
80
|
+
throw invalid();
|
|
81
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Matches SQLite TEXT COLLATE BINARY for canonical UTF-8 paths and ASCII role names. */
|
|
2
|
+
export function compareClassConsumerKeys(left, right) {
|
|
3
|
+
return (Buffer.compare(Buffer.from(left.file), Buffer.from(right.file)) ||
|
|
4
|
+
left.startByte - right.startByte ||
|
|
5
|
+
left.endByte - right.endByte ||
|
|
6
|
+
Buffer.compare(Buffer.from(left.kind), Buffer.from(right.kind)));
|
|
7
|
+
}
|
|
8
|
+
/** Adapters translate these typed failures; never manufacture a successful target identity. */
|
|
9
|
+
export class ClassConsumerQueryError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
candidates;
|
|
12
|
+
constructor(code, message, candidates = []) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.candidates = candidates;
|
|
16
|
+
this.name = "ClassConsumerQueryError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { signAuthenticatedCursor, verifyAuthenticatedCursor } from "./authenticated-cursor.js";
|
|
2
|
+
import { canonicalCoveragePath } from "./engine/index-coverage.js";
|
|
3
|
+
const PREFIX = "cc1";
|
|
4
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
5
|
+
const KINDS = [
|
|
6
|
+
"static_field_read",
|
|
7
|
+
"constructor",
|
|
8
|
+
"variable_type",
|
|
9
|
+
"cast",
|
|
10
|
+
"class_literal",
|
|
11
|
+
"parameter_type",
|
|
12
|
+
"generic_parameter_type",
|
|
13
|
+
"loop_variable_type",
|
|
14
|
+
"generic_type_argument",
|
|
15
|
+
];
|
|
16
|
+
function record(value) {
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
18
|
+
return false;
|
|
19
|
+
const prototype = Object.getPrototypeOf(value);
|
|
20
|
+
return prototype === Object.prototype || prototype === null;
|
|
21
|
+
}
|
|
22
|
+
function exactKeys(value, keys) {
|
|
23
|
+
return (Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)));
|
|
24
|
+
}
|
|
25
|
+
function validPayload(value) {
|
|
26
|
+
if (!record(value) || !exactKeys(value, ["v", "type", "queryDigest", "snapshotDigest", "after"]))
|
|
27
|
+
return false;
|
|
28
|
+
if (value.v !== 1 ||
|
|
29
|
+
value.type !== "class-consumers" ||
|
|
30
|
+
typeof value.queryDigest !== "string" ||
|
|
31
|
+
!DIGEST.test(value.queryDigest) ||
|
|
32
|
+
typeof value.snapshotDigest !== "string" ||
|
|
33
|
+
!DIGEST.test(value.snapshotDigest))
|
|
34
|
+
return false;
|
|
35
|
+
const key = value.after;
|
|
36
|
+
return (record(key) &&
|
|
37
|
+
exactKeys(key, ["file", "startByte", "endByte", "kind"]) &&
|
|
38
|
+
typeof key.file === "string" &&
|
|
39
|
+
canonicalCoveragePath(key.file) &&
|
|
40
|
+
Buffer.from(key.file).toString("utf8") === key.file &&
|
|
41
|
+
typeof key.startByte === "number" &&
|
|
42
|
+
Number.isSafeInteger(key.startByte) &&
|
|
43
|
+
key.startByte >= 0 &&
|
|
44
|
+
typeof key.endByte === "number" &&
|
|
45
|
+
Number.isSafeInteger(key.endByte) &&
|
|
46
|
+
key.endByte > key.startByte &&
|
|
47
|
+
typeof key.kind === "string" &&
|
|
48
|
+
KINDS.includes(key.kind));
|
|
49
|
+
}
|
|
50
|
+
function invalid() {
|
|
51
|
+
return new Error("invalid class-consumer continuation; restart the query without continuation");
|
|
52
|
+
}
|
|
53
|
+
/** Only call after the final wire envelope is fitted, using its last delivered site key. */
|
|
54
|
+
export function signClassConsumerCursor(payload, secret) {
|
|
55
|
+
if (!validPayload(payload))
|
|
56
|
+
throw invalid();
|
|
57
|
+
// Copy validated fields explicitly: signing never invokes a caller-provided toJSON hook.
|
|
58
|
+
return signAuthenticatedCursor({
|
|
59
|
+
v: 1,
|
|
60
|
+
type: "class-consumers",
|
|
61
|
+
queryDigest: payload.queryDigest,
|
|
62
|
+
snapshotDigest: payload.snapshotDigest,
|
|
63
|
+
after: {
|
|
64
|
+
file: payload.after.file,
|
|
65
|
+
startByte: payload.after.startByte,
|
|
66
|
+
endByte: payload.after.endByte,
|
|
67
|
+
kind: payload.after.kind,
|
|
68
|
+
},
|
|
69
|
+
}, secret, PREFIX);
|
|
70
|
+
}
|
|
71
|
+
/** Authenticates and validates shape. Backend must still bind both digests to its current lease. */
|
|
72
|
+
export function readClassConsumerCursor(cursor, secret) {
|
|
73
|
+
let payload;
|
|
74
|
+
try {
|
|
75
|
+
payload = verifyAuthenticatedCursor(cursor, secret, PREFIX);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw invalid();
|
|
79
|
+
}
|
|
80
|
+
if (!validPayload(payload))
|
|
81
|
+
throw invalid();
|
|
82
|
+
return payload;
|
|
83
|
+
}
|
|
84
|
+
export function assertClassConsumerCursorBinding(cursor, queryDigest, snapshotDigest) {
|
|
85
|
+
if (!validPayload(cursor) || !DIGEST.test(queryDigest) || !DIGEST.test(snapshotDigest))
|
|
86
|
+
throw invalid();
|
|
87
|
+
if (cursor.queryDigest !== queryDigest)
|
|
88
|
+
throw new Error("class-consumer continuation belongs to another query; restart without continuation");
|
|
89
|
+
if (cursor.snapshotDigest !== snapshotDigest)
|
|
90
|
+
throw new Error("class-consumer evidence changed; restart the query without continuation");
|
|
91
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ClassConsumerQueryError, } from "./class-consumer-contract.js";
|
|
2
|
+
/**
|
|
3
|
+
* Run once after the captured post-read health probe and before page packing.
|
|
4
|
+
* Revalidates the backend facts/coverage lease, not only freshness timestamps.
|
|
5
|
+
* Never replace the original page's rows or freshness with the validation read.
|
|
6
|
+
* Current seam repeats a limit-one raw read; measure before optimizing it into
|
|
7
|
+
* a dedicated lease check. Do not call it per candidate in the packing loop.
|
|
8
|
+
*/
|
|
9
|
+
export async function validateClassConsumerDelivery(page, request, read) {
|
|
10
|
+
const checked = await read({
|
|
11
|
+
...request,
|
|
12
|
+
limit: 1,
|
|
13
|
+
expectedQueryDigest: page.queryDigest,
|
|
14
|
+
expectedSnapshotDigest: page.snapshotDigest,
|
|
15
|
+
});
|
|
16
|
+
if (checked.queryDigest !== page.queryDigest ||
|
|
17
|
+
checked.targetIdentity !== page.targetIdentity ||
|
|
18
|
+
checked.targetKind !== page.targetKind)
|
|
19
|
+
throw new ClassConsumerQueryError("CLASS_CONSUMER_QUERY_MISMATCH", "The class-consumer query changed before delivery.");
|
|
20
|
+
if (checked.snapshotDigest !== page.snapshotDigest)
|
|
21
|
+
throw new ClassConsumerQueryError("CLASS_CONSUMER_STALE_CURSOR", "The class-consumer evidence changed before delivery; restart the query.");
|
|
22
|
+
}
|