knodin 0.10.1 → 0.10.3

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 CHANGED
@@ -27,7 +27,7 @@ import { exportContext, grepPackedArtifact, readPackedArtifact } from "../src/co
27
27
  import { clearDiagnostics, collectDiagnostics, diagnosticsStatus, disableDiagnostics, enableDiagnostics, inspectDiagnosticsBundle, persistDiagnosticsPreview, recordDiagnosticFailure, } from "../src/diagnostics.js";
28
28
  import { getDocSection, listDocTopics } from "../src/docs-sections.js";
29
29
  import { diagnoseInstallation } from "../src/doctor.js";
30
- import { createEngine, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
30
+ import { createEngine, describeThrown, KNODIN_SCHEMA_VERSION, REPO_WIDE_QUERY_PATTERNS, } from "../src/engine/index.js";
31
31
  import { runSeal } from "../src/engine/seal-command.js";
32
32
  import { runSealedQuery } from "../src/engine/sealed-query.js";
33
33
  import { resolveDbPath } from "../src/engine/state-paths.js";
@@ -138,16 +138,29 @@ function formatRepairHuman(result) {
138
138
  const detail = firstIssue ? ` First issue: ${firstIssue}.` : "";
139
139
  return `Repair finished with ${outstanding.toLocaleString()} remaining issue(s).${detail} Run \`knodin status --deep\` for details.\n`;
140
140
  }
141
+ /**
142
+ * The semantic gap, stated rather than left to be discovered.
143
+ *
144
+ * An index that deferred embeddings leaves `search` returning a short list, and
145
+ * a short list is indistinguishable from a thorough search that found little.
146
+ * Saying nothing here is what turns a deliberate deferral into a silent one.
147
+ */
148
+ function formatSemanticGap(readiness) {
149
+ if (readiness === undefined || readiness === "ready")
150
+ return "";
151
+ return ` Semantic coverage is ${readiness}: \`search\` will under-return until \`knodin index\` completes embeddings.`;
152
+ }
141
153
  function formatIndexHuman(result) {
154
+ const semantic = formatSemanticGap(result.semanticReadiness);
142
155
  if (result.indexed.length === 0 && result.unchanged.length > 0) {
143
156
  const noun = result.unchanged.length === 1 ? "file" : "files";
144
- return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.\n`;
157
+ return `Graph already current: ${result.unchanged.length.toLocaleString()} requested ${noun} needed no work; health verified.${semantic}\n`;
145
158
  }
146
159
  const unchanged = result.unchanged.length > 0
147
160
  ? `; ${result.unchanged.length.toLocaleString()} already current`
148
161
  : "";
149
162
  const noun = result.indexed.length === 1 ? "file" : "files";
150
- return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.\n`;
163
+ return `Index complete: ${result.indexed.length.toLocaleString()} ${noun} indexed${unchanged}; graph health verified.${semantic}\n`;
151
164
  }
152
165
  function formatIndexVerificationError(result) {
153
166
  const firstIssue = result.verification.missing.files[0] ?? result.verification.missing.records[0];
@@ -199,10 +212,16 @@ function formatCoverageGaps(skipped) {
199
212
  if (byExtension)
200
213
  clauses.push(`not indexed: ${byExtension}`);
201
214
  const unparsed = formatTally(skipped.unparsedByExtension);
215
+ // "not parsed", not "parsed but empty". Nothing in this tally was parsed:
216
+ // it collects files with no tree-sitter grammar, minified sources, and files
217
+ // whose indexing threw. Calling that "parsed but empty" asserts the parser
218
+ // looked and found nothing, which is the one thing it did not do — and it
219
+ // made a real outage unreadable, since a dead wasm module dumped thousands
220
+ // of never-parsed files into this bucket under a label saying they were fine.
202
221
  if (unparsed)
203
- clauses.push(`parsed but empty: ${unparsed}`);
222
+ clauses.push(`not parsed: ${unparsed}`);
204
223
  if (skipped.unparsedUnknown)
205
- clauses.push("parsed-but-empty tally unavailable — run a full index; this is a lower bound");
224
+ clauses.push("not-parsed tally unavailable — run a full index; this is a lower bound");
206
225
  const count = skipped.unparsedUnknown ? `${skipped.total}+` : `${skipped.total}`;
207
226
  return `. Not in graph: ${count} files (${clauses.join("; ")})`;
208
227
  }
@@ -2124,17 +2143,27 @@ async function main() {
2124
2143
  const agents = scope === "team" ? [] : integrationAgents(repo);
2125
2144
  const paths = await initializeRepository(repo, {
2126
2145
  command: runtimeCommand,
2127
- index: indexOrSeed(engine, KNODIN_SCHEMA_VERSION),
2146
+ // Not `indexOrSeed`: seeding exists for a worktree with no database,
2147
+ // and this command has already refused to run without one. Indexing
2148
+ // the written files directly is the whole point of the narrow scope.
2149
+ index: (target, indexOptions, files) => engine.index(target, files, false, indexOptions),
2150
+ indexScope: "configuration",
2128
2151
  scope,
2129
2152
  agents,
2130
2153
  allowTrackedTransition: true,
2131
2154
  auditConfigurationChanges: true,
2132
2155
  configureIntegration: true,
2133
2156
  });
2157
+ const indexed = paths.scopedIndexPaths ?? [];
2134
2158
  result = {
2135
2159
  status: "success",
2136
2160
  message: `knodin agent integration changed to ${scope}.`,
2137
- graphInitialization: "refreshed",
2161
+ // Say what happened. Claiming "refreshed" after touching a few
2162
+ // configuration files misdescribes the graph's state, and did so most
2163
+ // misleadingly on a repository whose graph was in fact empty.
2164
+ graphInitialization: indexed.length === 0
2165
+ ? "unchanged — no configuration files needed indexing"
2166
+ : `configuration files indexed (${indexed.length})`,
2138
2167
  nextAction: "run `knodin status` and reload the configured client",
2139
2168
  paths,
2140
2169
  };
@@ -2152,6 +2181,8 @@ async function main() {
2152
2181
  process.exit(1);
2153
2182
  }
2154
2183
  const clean = rest.includes("--clean") || rest.includes("--force");
2184
+ const skipEmbeddings = rest.includes("--skip-embeddings");
2185
+ const under = selectorValue("--under");
2155
2186
  const scipPath = selectorValue("--scip");
2156
2187
  const sarifPath = selectorValue("--sarif");
2157
2188
  // A bound the operator cannot move is just a failure, so every import
@@ -2182,6 +2213,8 @@ async function main() {
2182
2213
  let indexResult;
2183
2214
  try {
2184
2215
  indexResult = await engine.index(plan.repo, plan.files, clean, {
2216
+ skipEmbeddings,
2217
+ ...(under !== undefined ? { under } : {}),
2185
2218
  scip: scipPath
2186
2219
  ? {
2187
2220
  path: scipPath,
@@ -2888,6 +2921,17 @@ async function main() {
2888
2921
  process.exitCode = finalExitCode;
2889
2922
  return;
2890
2923
  }
2924
+ // `search` has no human formatter — results fall through to the generic JSON
2925
+ // emitter — so a semantically incomplete graph produced a short list with
2926
+ // nothing saying why. The list looks like a thorough answer, which is the
2927
+ // whole failure. Printed to stderr so it annotates without corrupting output
2928
+ // anyone is piping.
2929
+ if (cmd === "search" && !jsonOutput) {
2930
+ const readiness = result.semanticReadiness;
2931
+ const gap = formatSemanticGap(readiness);
2932
+ if (gap)
2933
+ process.stderr.write(`${gap.trim()}\n`);
2934
+ }
2891
2935
  if (cmd === "status" && !jsonOutput) {
2892
2936
  process.stdout.write(formatStatusHuman(boundedResult));
2893
2937
  process.exitCode = finalExitCode;
@@ -2985,7 +3029,7 @@ catch (err) {
2985
3029
  error: err,
2986
3030
  });
2987
3031
  const correlation = diagnostic.recorded ? ` [diagnostic ${diagnostic.correlationId}]` : "";
2988
- console.error(`${err instanceof Error ? err.message : String(err)}${correlation}`);
3032
+ console.error(`${describeThrown(err)}${correlation}`);
2989
3033
  process.exit(1);
2990
3034
  }
2991
3035
  }
@@ -36,6 +36,10 @@ const GLOBAL_BOOLEAN_FLAGS = new Set(["--exclude-tests", "--data-flow", "--json"
36
36
  * not to index the log as a source file.
37
37
  */
38
38
  const COMMAND_VALUE_FLAGS = new Set([
39
+ // Takes a directory. Without this its argument reads as a positional, and
40
+ // `knodin index --under src` would be rejected as "a directory positional"
41
+ // by the very rule that exists to stop people scoping this way by accident.
42
+ "--under",
39
43
  "--scip",
40
44
  "--scip-max-bytes",
41
45
  "--scip-max-files",
@@ -288,6 +288,8 @@ function createCliProgram(capture = () => { }) {
288
288
  leaf(program, "index [files...]", "index a repository or selected files", capture)
289
289
  .option("--clean", "rebuild selected index state")
290
290
  .option("--force", "force clean indexing")
291
+ .option("--skip-embeddings", "build structure only and defer semantic embeddings; `search` under-returns until a later `knodin index` completes them")
292
+ .option("--under <dir>", "rebuild only the files under a repository-relative directory; coverage is then reported as a lower bound until a full index runs")
291
293
  .option("--scip <file>", "opt in to a bounded local SCIP protobuf import")
292
294
  .addOption(option("--scip-max-bytes <count>", "raise the SCIP input size ceiling", "integer"))
293
295
  .addOption(option("--scip-max-files <count>", "raise the SCIP document ceiling", "integer"))
@@ -66,6 +66,59 @@ export function candidateRoot(repo) {
66
66
  export function promotionMarkerPath(repo) {
67
67
  return path.join(resolveStateDir(repo), PROMOTION_MARKER);
68
68
  }
69
+ /**
70
+ * Candidates left on disk for this repository, newest first.
71
+ *
72
+ * A clean index discards its candidate on failure, but a killed process runs no
73
+ * discard — so survivors are exactly the runs that were interrupted rather than
74
+ * the ones that failed. Listing them is what lets the next run continue instead
75
+ * of repeating hours of work.
76
+ *
77
+ * Returns descriptors only. Whether any of them is safe to resume is decided by
78
+ * the caller from the database's own contents, not from the fact it exists.
79
+ */
80
+ export function listCandidates(repo) {
81
+ const resolvedRepo = path.resolve(repo);
82
+ const root = candidateRoot(resolvedRepo);
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(root, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const found = [];
91
+ for (const entry of entries) {
92
+ if (!entry.isDirectory() || !entry.name.startsWith("candidate-"))
93
+ continue;
94
+ const databasePath = path.join(root, entry.name, "db.sqlite");
95
+ let modifiedMs;
96
+ try {
97
+ modifiedMs = fs.statSync(databasePath).mtimeMs;
98
+ }
99
+ catch {
100
+ // A directory with no database is a half-created candidate, not a
101
+ // resumable one.
102
+ continue;
103
+ }
104
+ found.push({
105
+ candidate: {
106
+ schemaVersion: 1,
107
+ id: entry.name,
108
+ repo: resolvedRepo,
109
+ databasePath,
110
+ createdAt: new Date(modifiedMs).toISOString(),
111
+ },
112
+ modifiedMs,
113
+ });
114
+ }
115
+ // Sorted as its own statement rather than mid-chain: an in-place `sort`
116
+ // inside an expression mutates the array being read, which is harmless for
117
+ // this local but reads as a side effect at a glance. (`toSorted` would be
118
+ // the nicer form but needs a newer `lib` than this project targets.)
119
+ found.sort((a, b) => b.modifiedMs - a.modifiedMs);
120
+ return found.map((item) => item.candidate);
121
+ }
69
122
  export function allocateCandidate(repo) {
70
123
  const resolvedRepo = path.resolve(repo);
71
124
  const root = candidateRoot(resolvedRepo);