simt-dev 0.7.0 → 0.8.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.
Files changed (235) hide show
  1. package/CHANGELOG.md +191 -870
  2. package/README.md +188 -213
  3. package/bin/simt.mjs +19 -55
  4. package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
  5. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  7. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  8. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  9. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  10. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  11. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  12. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  13. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
  14. package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
  15. package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
  16. package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
  17. package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
  18. package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
  19. package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
  20. package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
  21. package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
  22. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  23. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  24. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  25. package/ecosystems/bun.json +6 -0
  26. package/ecosystems/cargo.json +25 -0
  27. package/ecosystems/composer.json +6 -0
  28. package/ecosystems/dart.json +24 -0
  29. package/ecosystems/deno.json +21 -0
  30. package/ecosystems/devcontainers.json +6 -0
  31. package/ecosystems/docker-compose.json +6 -0
  32. package/ecosystems/docker.json +6 -0
  33. package/ecosystems/dotnet.json +25 -0
  34. package/ecosystems/elixir.json +23 -0
  35. package/ecosystems/gitsubmodule.json +6 -0
  36. package/ecosystems/go.json +25 -0
  37. package/ecosystems/gradle.json +13 -0
  38. package/ecosystems/maven.json +23 -0
  39. package/ecosystems/node.json +25 -0
  40. package/ecosystems/python.json +20 -0
  41. package/ecosystems/ruby.json +23 -0
  42. package/ecosystems/swift.json +25 -0
  43. package/ecosystems/terraform.json +6 -0
  44. package/ecosystems/uv.json +6 -0
  45. package/harnesses/antigravity.json +162 -0
  46. package/harnesses/claude.json +206 -0
  47. package/harnesses/codex.json +136 -0
  48. package/harnesses/copilot.json +88 -0
  49. package/harnesses/cursor.json +140 -0
  50. package/harnesses/deepseek.json +99 -0
  51. package/harnesses/gemini.json +122 -0
  52. package/harnesses/opencode.json +173 -0
  53. package/package.json +12 -2
  54. package/rules/core.md +7 -0
  55. package/rules/mdc/intake-questions.mdc +8 -0
  56. package/rules/mdc/slice-commit.mdc +57 -4
  57. package/src/advice/advice.mjs +3 -2
  58. package/src/agenthooks/prompt.mjs +5 -2
  59. package/src/agenthooks/render.mjs +86 -180
  60. package/src/agenthooks/shim.mjs +1 -1
  61. package/src/agents/render.mjs +15 -18
  62. package/src/api.mjs +31 -0
  63. package/src/argv.mjs +48 -3
  64. package/src/bench/ablate.mjs +3 -4
  65. package/src/bench/adapters.json +163 -105
  66. package/src/bench/adapters.mjs +466 -0
  67. package/src/bench/arms.mjs +333 -0
  68. package/src/bench/grade.mjs +33 -73
  69. package/src/bench/graders.mjs +42 -0
  70. package/src/bench/mine.mjs +121 -475
  71. package/src/bench/probe-ledger.mjs +149 -0
  72. package/src/bench/probes.catalog.mjs +918 -0
  73. package/src/bench/probes.mjs +79 -1064
  74. package/src/bench/render.mjs +111 -0
  75. package/src/bench/report.mjs +120 -962
  76. package/src/bench/run.mjs +185 -802
  77. package/src/bench/variants.mjs +4 -5
  78. package/src/bench/workspace.mjs +160 -0
  79. package/src/brief/assemble.mjs +48 -15
  80. package/src/ci/dependabot.mjs +25 -55
  81. package/src/ci/workflow.mjs +30 -7
  82. package/src/cli.mjs +45 -0
  83. package/src/commands/agent.mjs +4 -13
  84. package/src/commands/agenthook.mjs +70 -40
  85. package/src/commands/bench.mjs +191 -18
  86. package/src/commands/brief.mjs +5 -4
  87. package/src/commands/ci.mjs +13 -14
  88. package/src/commands/compare.mjs +42 -42
  89. package/src/commands/completions.mjs +2 -6
  90. package/src/commands/decide.mjs +4 -4
  91. package/src/commands/distill.mjs +4 -3
  92. package/src/commands/docs.mjs +88 -0
  93. package/src/commands/doctor.mjs +11 -7
  94. package/src/commands/evidence.mjs +6 -9
  95. package/src/commands/explain.mjs +0 -1
  96. package/src/commands/gate.mjs +26 -25
  97. package/src/commands/graph.mjs +2 -3
  98. package/src/commands/harness.mjs +6 -5
  99. package/src/commands/help.mjs +168 -19
  100. package/src/commands/hooks.mjs +119 -49
  101. package/src/commands/ignore.mjs +13 -8
  102. package/src/commands/impact.mjs +8 -15
  103. package/src/commands/index.mjs +318 -193
  104. package/src/commands/init-scan.mjs +90 -0
  105. package/src/commands/init.mjs +124 -105
  106. package/src/commands/lease.mjs +73 -25
  107. package/src/commands/lessons.mjs +10 -9
  108. package/src/commands/local.mjs +3 -3
  109. package/src/commands/map.mjs +24 -14
  110. package/src/commands/mcp.mjs +2 -18
  111. package/src/commands/pack.mjs +9 -6
  112. package/src/commands/plan.mjs +6 -5
  113. package/src/commands/plugin.mjs +2 -2
  114. package/src/commands/prbody.mjs +3 -3
  115. package/src/commands/repoindex.mjs +12 -11
  116. package/src/commands/review.mjs +2 -2
  117. package/src/commands/rules.mjs +37 -16
  118. package/src/commands/shared.mjs +14 -19
  119. package/src/commands/skills.mjs +33 -32
  120. package/src/commands/skillscompile.mjs +7 -8
  121. package/src/commands/skillsinstall.mjs +7 -6
  122. package/src/commands/slice.mjs +220 -74
  123. package/src/commands/spec.mjs +167 -0
  124. package/src/commands/status.mjs +27 -18
  125. package/src/commands/sync.mjs +27 -25
  126. package/src/commands/tests.mjs +18 -21
  127. package/src/commands/tokens.mjs +21 -26
  128. package/src/commands/undo.mjs +5 -15
  129. package/src/commands/upgrade.mjs +15 -7
  130. package/src/commands/why.mjs +15 -21
  131. package/src/compare/hooksstate.mjs +2 -6
  132. package/src/compare/learn.mjs +3 -1
  133. package/src/compare/row.mjs +0 -2
  134. package/src/context.mjs +64 -0
  135. package/src/decisions/index-doc.mjs +46 -0
  136. package/src/decisions/index.mjs +3 -2
  137. package/src/diff.mjs +1 -1
  138. package/src/distill/distill.mjs +14 -1
  139. package/src/distill/engine.mjs +5 -1
  140. package/src/docs/commands.mjs +65 -0
  141. package/src/ecosystem/records.mjs +219 -0
  142. package/src/explain.mjs +31 -0
  143. package/src/gates/index.mjs +385 -0
  144. package/src/graph/compile.mjs +8 -4
  145. package/src/graph/load.mjs +6 -2
  146. package/src/graph/spec.mjs +10 -8
  147. package/src/harness/records.mjs +143 -0
  148. package/src/hooks/capability.mjs +7 -5
  149. package/src/hooks/dispatcher.mjs +209 -60
  150. package/src/hooks/gates.mjs +10 -43
  151. package/src/hooks/resolve.mjs +4 -14
  152. package/src/hooks/shim.mjs +5 -2
  153. package/src/hooks/where.mjs +2 -7
  154. package/src/identity.mjs +31 -12
  155. package/src/index/coverage.mjs +58 -0
  156. package/src/index/extract.mjs +6 -16
  157. package/src/index/gate.mjs +1 -1
  158. package/src/index/govern.mjs +26 -9
  159. package/src/index/impact.mjs +4 -8
  160. package/src/index/map.mjs +14 -15
  161. package/src/index/open.mjs +173 -0
  162. package/src/index/persist.mjs +224 -57
  163. package/src/index/store.mjs +82 -39
  164. package/src/index/tests.mjs +2 -6
  165. package/src/journal.mjs +94 -20
  166. package/src/kernel/card.mjs +37 -63
  167. package/src/kernel/checks.mjs +4 -10
  168. package/src/lease.mjs +102 -7
  169. package/src/ledger/index.mjs +150 -14
  170. package/src/local/client.mjs +4 -6
  171. package/src/locate.mjs +33 -9
  172. package/src/manifest/schema.mjs +213 -0
  173. package/src/mcp/paginate.mjs +33 -0
  174. package/src/mcp/resources.mjs +7 -10
  175. package/src/mcp/schemas.mjs +512 -0
  176. package/src/mcp/server.mjs +119 -25
  177. package/src/mcp/tools-facts.mjs +326 -0
  178. package/src/mcp/tools-read.mjs +221 -0
  179. package/src/mcp/tools.mjs +4 -967
  180. package/src/measure/corpus.mjs +150 -0
  181. package/src/measure/metrics.mjs +236 -0
  182. package/src/measure/records.mjs +211 -0
  183. package/src/measure/session.mjs +3 -2
  184. package/src/measure/stats.mjs +538 -0
  185. package/src/measure/task-schema.mjs +125 -0
  186. package/src/measure/tasks.mjs +69 -0
  187. package/src/memory/decide.mjs +6 -15
  188. package/src/memory/lesson-text.mjs +61 -0
  189. package/src/memory/lessons.mjs +29 -82
  190. package/src/name.mjs +7 -0
  191. package/src/node-version.mjs +37 -0
  192. package/src/output/index.mjs +209 -10
  193. package/src/packs/manage.mjs +32 -12
  194. package/src/packs.mjs +20 -2
  195. package/src/plan/index.mjs +1 -1
  196. package/src/plugins.mjs +22 -16
  197. package/src/prbody.mjs +7 -11
  198. package/src/projection/loaders.mjs +7 -692
  199. package/src/projection/lock.mjs +11 -1
  200. package/src/projection/manifest.mjs +29 -468
  201. package/src/projection/mdc.mjs +7 -8
  202. package/src/projection/project.mjs +126 -221
  203. package/src/projection/region.mjs +561 -0
  204. package/src/projection/rules.mjs +20 -550
  205. package/src/projection/surfaces.mjs +43 -210
  206. package/src/projection/targets.mjs +5 -5
  207. package/src/repo-root.mjs +31 -0
  208. package/src/resolve/eval.mjs +2 -1
  209. package/src/resolve/fingerprint.mjs +19 -22
  210. package/src/resolve/resolve.mjs +1 -41
  211. package/src/review/critic.mjs +10 -13
  212. package/src/run.mjs +98 -0
  213. package/src/secrets.mjs +14 -22
  214. package/src/state/events.mjs +129 -0
  215. package/src/status-snapshot.mjs +233 -0
  216. package/src/status.mjs +2 -6
  217. package/src/sys/git.mjs +82 -0
  218. package/src/sys/proc.mjs +151 -0
  219. package/src/testing/parsers.mjs +265 -0
  220. package/src/testing/patches.mjs +23 -0
  221. package/src/testing/paths.mjs +49 -0
  222. package/src/testing/runners.mjs +186 -0
  223. package/src/testing/vault.mjs +68 -0
  224. package/src/text/frontmatter.mjs +49 -0
  225. package/src/text/tokens.mjs +52 -0
  226. package/src/toolwords.mjs +3 -0
  227. package/src/util/fs.mjs +20 -0
  228. package/src/util/sort.mjs +12 -0
  229. package/src/workspace.mjs +58 -11
  230. package/src/write/plan.mjs +82 -0
  231. package/templates/plan/tracker.json +1 -0
  232. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  233. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  234. package/docs/decisions/README.md +0 -600
  235. package/src/bench/tasks.mjs +0 -36
@@ -0,0 +1,58 @@
1
+ // Which tests a change should run, from one place (Q77).
2
+ //
3
+ // Four callers used to answer this question four ways: `tests` walked the stored test
4
+ // edges directly, `gate` walked the import graph transitively over a re-read worktree,
5
+ // `impact` asked `dependents` which asked `selectTests`, and `brief` used the edges again.
6
+ // The answers disagreed — `simt tests src/index/persist.mjs` named one test where `simt
7
+ // gate` on the same file named dozens — and every disagreement was a reader trusting the
8
+ // wrong one.
9
+ //
10
+ // The two walks are not the same question, so they are not merged into one answer:
11
+ // `transitive` is the import closure over changed paths, and the direct walk is the test
12
+ // edges the index stores. Each caller keeps its default; what changes is that there is
13
+ // now one implementation of each.
14
+
15
+ import { selectTests } from './gate.mjs';
16
+ import { testEdges, testsFor } from './tests.mjs';
17
+
18
+ /**
19
+ * Tests that exercise `paths`.
20
+ *
21
+ * @param {{files?: {path: string}[]|string[], imports?: {src: string, dst: string, kind?: string}[],
22
+ * coverage?: string[], commits?: string[][]}} graph
23
+ * @param {Iterable<string>} paths
24
+ * @param {{transitive?: boolean, cochange?: boolean}} [options]
25
+ * `transitive` (default true) is the import closure; `cochange` adds files that land in
26
+ * the same commits, and is only ever on when a caller asked for it.
27
+ * @returns {string[]}
28
+ */
29
+ export function coverage(graph, paths, { transitive = true, cochange = false } = {}) {
30
+ const changed = [...new Set([...paths].filter(Boolean))];
31
+ const imports = graph.imports ?? [];
32
+ if (transitive) {
33
+ const selected = selectTests(changed, imports);
34
+ if (!cochange || graph.files === undefined) return selected;
35
+ const edges = testEdges({
36
+ files: graph.files,
37
+ imports,
38
+ coverage: graph.coverage ?? [],
39
+ commits: graph.commits ?? [],
40
+ });
41
+ const found = new Set(selected);
42
+ for (const path of changed) {
43
+ for (const test of testsFor(path, edges, { cochange: true })) found.add(test);
44
+ }
45
+ return [...found].sort();
46
+ }
47
+ const edges = testEdges({
48
+ files: graph.files ?? [],
49
+ imports,
50
+ coverage: graph.coverage ?? [],
51
+ commits: graph.commits ?? [],
52
+ });
53
+ const found = new Set();
54
+ for (const path of changed) {
55
+ for (const test of testsFor(path, edges, { cochange })) found.add(test);
56
+ }
57
+ return [...found].sort();
58
+ }
@@ -4,21 +4,14 @@
4
4
  // line at a time: an export, a definition, an import. That is enough for a map
5
5
  // and an import graph, and it is the same result on every machine.
6
6
 
7
+ import { cmp } from '../util/sort.mjs';
8
+ // Q70: the extension table is the records' `languages[].exts` (ADR-013: content is data,
9
+ // the engine is code). A new language lands in a record, not a row the extractor edits.
10
+ import { languageExts } from '../ecosystem/records.mjs';
11
+
7
12
  const IDENT = '[A-Za-z_$][\\w$]*';
8
13
 
9
- const EXTS = {
10
- javascript: ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx'],
11
- typescript: ['.ts', '.tsx', '.js', '.mjs', '.jsx'],
12
- kotlin: ['.kt', '.kts'],
13
- java: ['.java'],
14
- python: ['.py'],
15
- go: ['.go'],
16
- rust: ['.rs'],
17
- swift: ['.swift'],
18
- dart: ['.dart'],
19
- ruby: ['.rb'],
20
- csharp: ['.cs'],
21
- };
14
+ const EXTS = languageExts();
22
15
 
23
16
  /**
24
17
  * Symbols and import specifiers in one blob.
@@ -261,9 +254,6 @@ function byEdge(a, b) {
261
254
  return cmp(a.src, b.src) || cmp(a.dst, b.dst) || cmp(a.kind, b.kind);
262
255
  }
263
256
 
264
- function cmp(a, b) {
265
- return a < b ? -1 : a > b ? 1 : 0;
266
- }
267
257
 
268
258
  function sigOf(line) {
269
259
  return line.trim().slice(0, 200);
@@ -8,7 +8,7 @@
8
8
  import { readFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
 
11
- import { isTestPath } from '../bench/mine.mjs';
11
+ import { isTestPath } from '../testing/paths.mjs';
12
12
  import { langOf } from './store.mjs';
13
13
  import { importsOf } from './tests.mjs';
14
14
 
@@ -6,7 +6,9 @@
6
6
  // and a lesson do the same with their own path list. Nothing here is stored: the
7
7
  // answer is the records on disk.
8
8
 
9
- import { parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
9
+ import { parseDecisionFrontmatter } from '../memory/decide.mjs';
10
+ import { parseFrontmatter } from '../text/frontmatter.mjs';
11
+ import { cmp } from '../util/sort.mjs';
10
12
 
11
13
  const SIBLING = /^\*{0,2}[A-Z][\w-]*(?:[ \t]+[\w-]+){0,3}:\*{0,2}(?:[ \t]|$)/;
12
14
  const HEADING = /^#{1,6}\s/;
@@ -18,7 +20,7 @@ const TOOL = /^(?:simt-dev|simt)\b/;
18
20
  */
19
21
  export function affectsTokens(text) {
20
22
  const fm = parseDecisionFrontmatter(text);
21
- const { body } = splitFrontmatter(text);
23
+ const { body } = parseFrontmatter(text);
22
24
  const lines = String(body).split('\n');
23
25
  const start = lines.findIndex((line) => /^\s*\*{0,2}Affects:\*{0,2}/.test(line));
24
26
  const proseTokens = [];
@@ -131,7 +133,27 @@ export function scopeHits(file, { rules = [], skills = [], gates = [], lessons =
131
133
  return hits;
132
134
  }
133
135
 
134
- export function sortHits(hits) {
136
+ /**
137
+ * What governs one file: decisions whose Affects line names it, then the rules, skills,
138
+ * gates and lessons whose path lists match, in one stable order.
139
+ *
140
+ * `why`, `impact` and the critic each assembled this themselves, with different corpora
141
+ * and - in `why`'s case - a hard-coded list of this repository's own rule directories
142
+ * (Q77). The corpus is now the caller's, so a receiving repository's directories are the
143
+ * ones read.
144
+ *
145
+ * @param {string} file
146
+ * @param {{records?: {id: string, title: string, text: string}[], ctx: {files: Set<string>,
147
+ * commands: Set<string>, roots: string[]}, rules?: object[], skills?: object[],
148
+ * gates?: object[], lessons?: object[], commands?: Record<string, string>}} corpus
149
+ */
150
+ export function governs(file, corpus) {
151
+ const { records = [], ctx, ...scopes } = corpus;
152
+ const { hits } = decisionHits(records, file, ctx);
153
+ return sortHits([...hits, ...scopeHits(file, scopes)]);
154
+ }
155
+
156
+ function sortHits(hits) {
135
157
  const order = { decision: 0, rule: 1, skill: 2, gate: 3, lesson: 4 };
136
158
  return [...hits].sort((a, b) => (order[a.kind] - order[b.kind]) || cmp(a.id, b.id));
137
159
  }
@@ -218,7 +240,7 @@ function extension(file) {
218
240
  }
219
241
 
220
242
  function frontmatter(text) {
221
- return splitFrontmatter(text).front ?? '';
243
+ return parseFrontmatter(text).front ?? '';
222
244
  }
223
245
 
224
246
  function quotedList(front, key) {
@@ -227,8 +249,3 @@ function quotedList(front, key) {
227
249
  return [...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((item) => item[1]);
228
250
  }
229
251
 
230
- function cmp(a, b) {
231
- if (a < b) return -1;
232
- if (a > b) return 1;
233
- return 0;
234
- }
@@ -5,8 +5,9 @@
5
5
  // touches. The walk stops at a fixed count: an unbounded report is not an
6
6
  // answer a reader can use.
7
7
 
8
- import { isTestPath } from '../bench/mine.mjs';
9
- import { selectTests } from './gate.mjs';
8
+ import { isTestPath } from '../testing/paths.mjs';
9
+ import { coverage } from './coverage.mjs';
10
+ import { cmp } from '../util/sort.mjs';
10
11
 
11
12
  const IMPACT_LIMIT = 64;
12
13
 
@@ -50,7 +51,7 @@ export function dependents(file, imports, limit = IMPACT_LIMIT) {
50
51
  }
51
52
  }
52
53
  importers.sort();
53
- const tests = selectTests([file], imports).filter((path) => path !== file);
54
+ const tests = coverage({ imports }, [file]).filter((path) => path !== file);
54
55
  const shown = tests.length > limit ? tests.slice(0, limit) : tests;
55
56
  if (tests.length > limit) truncated = true;
56
57
  return { importers, tests: shown, truncated };
@@ -179,8 +180,3 @@ function glob(pattern, text) {
179
180
  return new RegExp(`^${body}$`).test(text);
180
181
  }
181
182
 
182
- function cmp(a, b) {
183
- if (a < b) return -1;
184
- if (a > b) return 1;
185
- return 0;
186
- }
package/src/index/map.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  import { readdir, readFile } from 'node:fs/promises';
10
10
  import { join } from 'node:path';
11
11
 
12
- import { isTestPath, resolveVaultDir } from '../bench/mine.mjs';
12
+ import { isTestPath } from '../testing/paths.mjs';
13
+ import { resolveVaultDir } from '../testing/vault.mjs';
13
14
  import { readLocalConfig } from '../local/config.mjs';
14
15
  import { localEmbed } from '../local/client.mjs';
15
16
  import {
@@ -22,6 +23,8 @@ import {
22
23
  scoreFtsBm25,
23
24
  searchFtsBm25,
24
25
  } from './store.mjs';
26
+ import { cmp } from '../util/sort.mjs';
27
+ import { INDEX_STOP as STOP } from '../text/tokens.mjs';
25
28
 
26
29
  export const ITERATIONS = 20;
27
30
  export const DAMPING = 0.85;
@@ -30,14 +33,6 @@ export const EMBED_RECALL_GAIN_BAR = 0.1;
30
33
  const TOP = 10;
31
34
  export const DEFAULT_BUDGET = 2048;
32
35
 
33
- const STOP = new Set([
34
- 'this', 'that', 'with', 'from', 'have', 'been', 'into', 'only', 'when', 'what',
35
- 'which', 'their', 'about', 'after', 'before', 'where', 'there', 'these', 'those',
36
- 'would', 'could', 'should', 'every', 'other', 'than', 'then', 'them', 'they',
37
- 'your', 'will', 'does', 'file', 'files', 'test', 'tests', 'line', 'lines', 'code',
38
- 'also', 'just', 'more', 'some', 'such', 'each', 'same', 'over', 'used', 'using',
39
- ]);
40
-
41
36
  /**
42
37
  * Reciprocal-rank fusion (RRF) over multiple ranked lists.
43
38
  *
@@ -405,16 +400,25 @@ function pageRank(nodes, links, scores) {
405
400
  let rank = pers.slice();
406
401
  for (let iter = 0; iter < ITERATIONS; iter += 1) {
407
402
  const next = pers.map((value) => (1 - DAMPING) * value);
403
+ // Dangling nodes (no outgoing edges) spread their rank over the whole
404
+ // distribution. Accumulating their mass first makes that one pass over j
405
+ // instead of one pass per dangling node - the no-task rank of a 30k-file
406
+ // repo used to go quadratic here (Q76).
407
+ let danglingMass = 0;
408
408
  for (let i = 0; i < n; i += 1) {
409
409
  const outs = links[i];
410
410
  if (outs.length === 0) {
411
- for (let j = 0; j < n; j += 1) next[j] += DAMPING * rank[i] * pers[j];
411
+ danglingMass += rank[i];
412
412
  } else {
413
413
  let sum = 0;
414
414
  for (const [, weight] of outs) sum += weight;
415
415
  for (const [j, weight] of outs) next[j] += (DAMPING * rank[i] * weight) / sum;
416
416
  }
417
417
  }
418
+ if (danglingMass > 0) {
419
+ const share = DAMPING * danglingMass;
420
+ for (let j = 0; j < n; j += 1) next[j] += share * pers[j];
421
+ }
418
422
  rank = next;
419
423
  }
420
424
  return rank;
@@ -620,8 +624,3 @@ function round6(value) {
620
624
  return Math.round(value * 1e6) / 1e6;
621
625
  }
622
626
 
623
- function cmp(a, b) {
624
- if (a < b) return -1;
625
- if (a > b) return 1;
626
- return 0;
627
- }
@@ -0,0 +1,173 @@
1
+ // The index as a handle (review 0003 Q76).
2
+ //
3
+ // `openIndex` returns a handle whose methods answer with pure data — rows and
4
+ // counts, no exit codes, no formatted lines — and with a structured error,
5
+ // `{ ok: false, error: { code, message } }`, when they cannot. The methods are
6
+ // thin over the same primitives the commands use, so a handle and a command
7
+ // cannot disagree about the same index.
8
+ //
9
+ // The graph loads once at open: files, symbols, edges. FTS bodies run in
10
+ // SQLite (`search` delegates to `searchFtsBm25`) and stored vectors are not
11
+ // loaded at all: a map without `--embed` does not touch them (Q76).
12
+
13
+ import { coverage } from './coverage.mjs';
14
+ import { governs } from './govern.mjs';
15
+ import { dependents } from './impact.mjs';
16
+ import { rankFiles } from './map.mjs';
17
+ import { isIndexStale, readEdges, readFiles, readSymbols, reindex, searchFtsBm25 } from './store.mjs';
18
+
19
+ const INDEX_CODES = {
20
+ BUILD: 'E_BUILD',
21
+ CLOSED: 'E_CLOSED',
22
+ NO_INDEX: 'E_NO_INDEX',
23
+ READ: 'E_READ',
24
+ };
25
+
26
+ function ok(data) {
27
+ return { ok: true, data };
28
+ }
29
+
30
+ function fail(code, message) {
31
+ return { ok: false, error: { code, message } };
32
+ }
33
+
34
+ /**
35
+ * The files, symbols and edges the index holds. The lazy projection of
36
+ * `loadGraph`: no FTS bodies, no vectors, because nothing on this handle
37
+ * scores them.
38
+ */
39
+ async function loadGraphData(root) {
40
+ const [files, symbols, edges] = await Promise.all([
41
+ readFiles(root),
42
+ readSymbols(root),
43
+ readEdges(root),
44
+ ]);
45
+ return { files, symbols, edges };
46
+ }
47
+
48
+ /**
49
+ * Open the repository's index.
50
+ *
51
+ * @param {string} root
52
+ * @param {{build?: boolean, refresh?: boolean}} [options]
53
+ * `build` (default true) refreshes the index from the git index before the
54
+ * first read — incrementally, so a current repository pays for one
55
+ * `git ls-files -s`, not a walk of the tree. `refresh` re-reads the graph on
56
+ * every call instead of caching it at open.
57
+ * @returns {Promise<IndexHandle | {ok: false, error: {code: string, message: string}}>}
58
+ */
59
+ export async function openIndex(root, { build = true, refresh = false } = {}) {
60
+ if (build) {
61
+ try {
62
+ await reindex(root);
63
+ } catch (error) {
64
+ return fail(INDEX_CODES.BUILD, `could not build the index (${error.message})`);
65
+ }
66
+ }
67
+
68
+ let closed = false;
69
+ let cached = null;
70
+
71
+ async function graph() {
72
+ if (closed) throw new Error(INDEX_CODES.CLOSED);
73
+ if (cached === null || refresh) cached = await loadGraphData(root);
74
+ return cached;
75
+ }
76
+
77
+ async function answered(fn) {
78
+ try {
79
+ return await fn();
80
+ } catch (error) {
81
+ if (error.message === INDEX_CODES.CLOSED) return fail(INDEX_CODES.CLOSED, 'the index is closed');
82
+ return fail(INDEX_CODES.READ, error.message);
83
+ }
84
+ }
85
+
86
+ const handle = {
87
+ ok: true,
88
+
89
+ /** Freshness and row counts of the open index. */
90
+ status: () =>
91
+ answered(async () => {
92
+ const g = await graph();
93
+ if (g.files.length === 0) return fail(INDEX_CODES.NO_INDEX, 'the index is empty');
94
+ const stale = await isIndexStale(root);
95
+ return ok({ stale, counts: { files: g.files.length, symbols: g.symbols.length, edges: g.edges.length } });
96
+ }),
97
+
98
+ /**
99
+ * BM25 rows for a task string, best first.
100
+ * @param {string} task
101
+ * @param {{limit?: number}} [options]
102
+ * @returns {Promise<{ok: true, data: {path: string, kind: string, score: number}[]}|{ok: false, error: {code: string, message: string}}>}
103
+ */
104
+ search: (task, { limit } = {}) =>
105
+ answered(async () => {
106
+ if (closed) throw new Error(INDEX_CODES.CLOSED);
107
+ const rows = await searchFtsBm25(root, task);
108
+ if (rows.error) return fail(INDEX_CODES.READ, rows.error);
109
+ return ok(limit === undefined ? rows : rows.slice(0, limit));
110
+ }),
111
+
112
+ /**
113
+ * Rank the repository's files for a task string (R22): graph proximity
114
+ * fused with the BM25 rows.
115
+ * @param {string} task
116
+ * @param {{limit?: number, embed?: boolean, allowRemote?: boolean}} [options]
117
+ * @returns {Promise<{ok: true, data: {path: string, rank: number, score: number}[]}|{ok: false, error: {code: string, message: string}}>}
118
+ */
119
+ rank: (task, { limit, embed = false, allowRemote = false } = {}) =>
120
+ answered(async () => {
121
+ const g = await graph();
122
+ if (g.files.length === 0) return fail(INDEX_CODES.NO_INDEX, 'the index is empty');
123
+ const bm25 = String(task ?? '').trim().length > 0 ? await searchFtsBm25(root, task) : [];
124
+ if (bm25.error) return fail(INDEX_CODES.READ, bm25.error);
125
+ const ranked = rankFiles({ ...g, bm25, task });
126
+ return ok(limit === undefined ? ranked : ranked.slice(0, limit));
127
+ }),
128
+
129
+ /**
130
+ * The tests that exercise `paths` (Q77): the import closure when
131
+ * `transitive` (default true), the stored test edges otherwise.
132
+ * @param {Iterable<string>} paths
133
+ * @param {{transitive?: boolean, cochange?: boolean}} [options]
134
+ * @returns {Promise<{ok: true, data: string[]}|{ok: false, error: {code: string, message: string}}>}
135
+ */
136
+ coverage: (paths, options) =>
137
+ answered(async () => {
138
+ const g = await graph();
139
+ return ok(coverage({ files: g.files, imports: g.edges }, paths, options));
140
+ }),
141
+
142
+ /**
143
+ * Who a change to `file` reaches (Q77): the reverse import closure and
144
+ * the tests that import it.
145
+ * @param {string} file
146
+ * @param {{limit?: number}} [options]
147
+ * @returns {Promise<{ok: true, data: {importers: string[], tests: string[], truncated: boolean}}|{ok: false, error: {code: string, message: string}}>}
148
+ */
149
+ impact: (file, { limit } = {}) =>
150
+ answered(async () => {
151
+ const g = await graph();
152
+ return ok(dependents(file, g.edges, limit));
153
+ }),
154
+
155
+ /**
156
+ * What governs `file` (Q77): the records in `corpus` whose Affects line
157
+ * names it, plus the rules, skills, gates and lessons whose path lists
158
+ * match.
159
+ * @param {string} file
160
+ * @param {{records?: {id: string, title: string, text: string}[], ctx?: {files: Set<string>, commands: Set<string>, roots: string[]}}} [corpus]
161
+ * @returns {Promise<{ok: true, data: {kind: string, id: string, detail?: string}[]}|{ok: false, error: {code: string, message: string}}>}
162
+ */
163
+ governs: (file, corpus) => answered(() => ok(governs(file, corpus ?? {}))),
164
+
165
+ /** Release the handle. Calls after it answer `E_CLOSED`. */
166
+ close() {
167
+ closed = true;
168
+ cached = null;
169
+ },
170
+ };
171
+
172
+ return handle;
173
+ }