simt-dev 0.7.1 → 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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -18,13 +18,12 @@
18
18
  // Report what did not run. The summary names every gate, its disposition and
19
19
  // its reason, so "passed" can never be mistaken for "only some of it passed".
20
20
 
21
- import { execFile, spawn } from 'node:child_process';
21
+ import { spawn } from 'node:child_process';
22
22
  import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { basename, join, matchesGlob, resolve, sep } from 'node:path';
25
- import { promisify } from 'node:util';
26
25
 
27
- import { GATES } from './gates.mjs';
26
+ import { GATES, getGate } from './gates.mjs';
28
27
  import { resolveGate } from './resolve.mjs';
29
28
  import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
30
29
  import { CLI } from '../name.mjs';
@@ -32,8 +31,7 @@ import { MANIFEST_FILE } from '../name.mjs';
32
31
  import { assertVersionPin } from '../package-version.mjs';
33
32
  import { hasTestEvidence, readLedger, recordRun } from '../ledger/index.mjs';
34
33
  import { renderFindings, scanStaged } from '../secrets.mjs';
35
-
36
- const exec = promisify(execFile);
34
+ import { git, gitPaths, gitText } from '../sys/git.mjs';
37
35
 
38
36
  /**
39
37
  * The default bound on one gate, in seconds, and the grace a killed gate gets before
@@ -194,15 +192,14 @@ export function parsePushedRefs(text) {
194
192
  async function tagCommitAccountedFor(root, tagSha, pushingTips) {
195
193
  let commit;
196
194
  try {
197
- ({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
195
+ commit = await gitText(root, ['rev-parse', `${tagSha}^{commit}`]);
198
196
  } catch (error) {
199
197
  return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
200
198
  }
201
- commit = commit.trim();
202
199
 
203
200
  let remoteRefs;
204
201
  try {
205
- ({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
202
+ remoteRefs = await gitText(root, ['for-each-ref', 'refs/remotes/', '--format=%(refname)']);
206
203
  } catch (error) {
207
204
  return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
208
205
  }
@@ -215,7 +212,7 @@ async function tagCommitAccountedFor(root, tagSha, pushingTips) {
215
212
  }
216
213
  for (const candidate of candidates) {
217
214
  try {
218
- await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
215
+ await git(root, ['merge-base', '--is-ancestor', commit, candidate]);
219
216
  return { ok: true, reason: null };
220
217
  } catch {
221
218
  // not reachable from this ref; try the next candidate
@@ -257,17 +254,17 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
257
254
  }
258
255
  let count, names;
259
256
  try {
260
- ({ stdout: count } = await exec('git', ['rev-list', '--count', `${remoteSha}..${localSha}`], { cwd: root }));
261
- ({ stdout: names } = await exec('git', ['diff', '--name-only', '-z', `${remoteSha}..${localSha}`], { cwd: root }));
257
+ count = await gitText(root, ['rev-list', '--count', `${remoteSha}..${localSha}`]);
258
+ names = await gitPaths(root, ['diff', '--name-only', `${remoteSha}..${localSha}`]);
262
259
  } catch (error) {
263
260
  return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
264
261
  }
265
- commits += Number(count.trim());
266
- // `-z` and no trimming: a path is NUL-terminated because a path can contain a
267
- // newline, and git quotes such a name in the line-terminated form, so splitting on
268
- // newlines compares a quoted path against an unquoted glob and never matches.
269
- for (const name of names.split('\0')) {
270
- if (name !== '') files.add(name);
262
+ commits += Number(count);
263
+ // NUL-separated paths because a path can contain a newline, and git quotes such a
264
+ // name in the line-terminated form, so splitting on newlines compares a quoted path
265
+ // against an unquoted glob and never matches.
266
+ for (const name of names) {
267
+ files.add(name);
271
268
  }
272
269
  }
273
270
 
@@ -339,11 +336,10 @@ async function resolveDocsOnlySkip(root, manifest, pushed) {
339
336
  async function resolveAlreadyPassedSkip(root) {
340
337
  let headTree;
341
338
  try {
342
- ({ stdout: headTree } = await exec('git', ['rev-parse', 'HEAD^{tree}'], { cwd: root }));
339
+ headTree = await gitText(root, ['rev-parse', 'HEAD^{tree}']);
343
340
  } catch {
344
341
  return null;
345
342
  }
346
- headTree = headTree.trim();
347
343
  if (!/^[0-9a-f]{40}$/.test(headTree)) return null;
348
344
  const { entries, errors } = await readLedger(root);
349
345
  if (errors.length > 0) return null;
@@ -508,8 +504,8 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
508
504
  let formatted = [];
509
505
 
510
506
  for (const name of declared) {
511
- const gate = GATES[name];
512
- if (gate === undefined) {
507
+ const gate = getGate(name, commands);
508
+ if (gate === null) {
513
509
  results.push({
514
510
  name,
515
511
  status: 'failed',
@@ -727,7 +723,7 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
727
723
  async function materializeHeadWorktree(root) {
728
724
  const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
729
725
  try {
730
- await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
726
+ await git(root, ['worktree', 'add', '--detach', path, 'HEAD']);
731
727
  } catch (error) {
732
728
  await rm(path, { recursive: true, force: true }).catch(() => {});
733
729
  return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
@@ -752,9 +748,9 @@ async function materializeHeadWorktree(root) {
752
748
  * @param {string} path the worktree to remove
753
749
  */
754
750
  async function removeHeadWorktree(root, path) {
755
- await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
751
+ await git(root, ['worktree', 'remove', '--force', path]).catch(async () => {
756
752
  await rm(path, { recursive: true, force: true }).catch(() => {});
757
- await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
753
+ await git(root, ['worktree', 'prune']).catch(() => {});
758
754
  });
759
755
  }
760
756
 
@@ -877,10 +873,9 @@ async function readPkg(root) {
877
873
  /** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
878
874
  export async function stagedFiles(root) {
879
875
  try {
880
- // `-z`: git quotes a path containing a newline or a quote in the line form, and a
881
- // quoted name is not the path the index holds, so it never matches a staged blob.
882
- const { stdout } = await exec('git', ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], { cwd: root });
883
- return stdout.split('\0').filter(Boolean);
876
+ // NUL-separated: git quotes a path containing a newline or a quote in the line form,
877
+ // and a quoted name is not the path the index holds, so it never matches a staged blob.
878
+ return await gitPaths(root, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']);
884
879
  } catch {
885
880
  return [];
886
881
  }
@@ -896,8 +891,7 @@ export async function stagedFiles(root) {
896
891
  */
897
892
  async function unstagedFiles(root) {
898
893
  try {
899
- const { stdout } = await exec('git', ['diff', '--name-only', '-z'], { cwd: root });
900
- return new Set(stdout.split('\0').filter(Boolean));
894
+ return new Set(await gitPaths(root, ['diff', '--name-only']));
901
895
  } catch {
902
896
  return new Set();
903
897
  }
@@ -907,7 +901,7 @@ async function unstagedFiles(root) {
907
901
  async function stagedBlobs(root, files) {
908
902
  if (files.length === 0) return new Map();
909
903
  try {
910
- const { stdout } = await exec('git', ['ls-files', '-s', '--', ...files], { cwd: root });
904
+ const stdout = await git(root, ['ls-files', '-s', '--', ...files]);
911
905
  const blobs = new Map();
912
906
  for (const line of stdout.split('\n')) {
913
907
  const [meta, ...rest] = line.split('\t');
@@ -932,7 +926,7 @@ async function restage(root, files) {
932
926
  if (files.length === 0) return { restaged: [], error: null };
933
927
  const before = await stagedBlobs(root, files);
934
928
  try {
935
- await exec('git', ['add', '--', ...files], { cwd: root });
929
+ await git(root, ['add', '--', ...files]);
936
930
  } catch (error) {
937
931
  return { restaged: [], error: String(error.stderr ?? error.message ?? error).trim() };
938
932
  }
@@ -101,8 +101,18 @@ export const GATES = {
101
101
  },
102
102
  };
103
103
 
104
- export function getGate(name) {
105
- return GATES[name] ?? null;
104
+ export function getGate(name, commands = {}) {
105
+ const declared = GATES[name];
106
+ if (declared) return declared;
107
+ // Q70: a command the repository declared under gates.commands is a valid gate even when
108
+ // no central row knows it. The manifest is the source of truth for a repo's gates, and
109
+ // a name that resolves to a declared command must not read as unknown. Synthesized, not
110
+ // added to GATES: the central table stays the estate's gates, the record supplies the rest.
111
+ const cmd = commands?.[name];
112
+ if (typeof cmd === 'string' && cmd.trim() !== '') {
113
+ return { name, kind: 'command', adr: 'ADR-010', why: 'declared by the repository under gates.commands' };
114
+ }
115
+ return null;
106
116
  }
107
117
 
108
118
  export function gateNames() {
@@ -12,7 +12,7 @@
12
12
 
13
13
  import { access } from 'node:fs/promises';
14
14
  import { constants } from 'node:fs';
15
- import { join } from 'node:path';
15
+ import { extname, join } from 'node:path';
16
16
 
17
17
  import { FORMATTABLE, GATES, gateNames } from './gates.mjs';
18
18
  import { MANIFEST_FILE } from '../name.mjs';
@@ -139,7 +139,7 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
139
139
  remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
140
140
  };
141
141
  }
142
- const files = staged.filter((f) => FORMATTABLE.has(extname(f)));
142
+ const files = staged.filter((f) => FORMATTABLE.has(extname(f).toLowerCase()));
143
143
  if (files.length === 0) {
144
144
  return {
145
145
  ok: true,
@@ -200,9 +200,4 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
200
200
  reason: `unknown gate kind "${gate.kind}"`,
201
201
  remedy: `this is a bug in simt-dev; )}', ')}`,
202
202
  };
203
- }
204
-
205
- function extname(path) {
206
- const dot = path.lastIndexOf('.');
207
- return dot > 0 ? path.slice(dot).toLowerCase() : '';
208
203
  }
@@ -19,17 +19,12 @@
19
19
  // ADR-012 dropped Windows, but nothing here is POSIX-specific; the worktree
20
20
  // handling is the only subtlety and it is the same on every platform.
21
21
 
22
- import { execFile } from 'node:child_process';
23
22
  import { readdir, stat } from 'node:fs/promises';
24
23
  import { isAbsolute, join, resolve } from 'node:path';
25
- import { promisify } from 'node:util';
26
24
 
27
- const exec = promisify(execFile);
25
+ import { gitText } from '../sys/git.mjs';
28
26
 
29
- async function git(root, args) {
30
- const { stdout } = await exec('git', args, { cwd: root });
31
- return stdout.trim();
32
- }
27
+ const git = (root, args) => gitText(root, args);
33
28
 
34
29
  /**
35
30
  * The directory git runs hooks from, and which of the two rules produced it.
package/src/identity.mjs CHANGED
@@ -24,14 +24,12 @@
24
24
  // one; a repository that already tracks it is left that way, because removing a
25
25
  // tracked file is not something an adopting command should do silently.
26
26
 
27
- import { execFile } from 'node:child_process';
28
27
  import { readFile, stat, writeFile } from 'node:fs/promises';
29
28
  import { join } from 'node:path';
30
- import { promisify } from 'node:util';
31
29
 
32
30
  import { journaledGitConfig, journaledWriteFile } from './journal.mjs';
31
+ import { gitText } from './sys/git.mjs';
33
32
 
34
- const exec = promisify(execFile);
35
33
 
36
34
  /** The file, at the repository root. Named for what it is: a git config fragment. */
37
35
  export const IDENTITY_FILE = 'git.config';
@@ -50,14 +48,7 @@ export const INCLUDE_KEY = 'include.path';
50
48
  * @param {string[]} args
51
49
  * @returns {Promise<string|null>}
52
50
  */
53
- async function git(cwd, args) {
54
- try {
55
- const { stdout } = await exec('git', args, { cwd });
56
- return stdout.trim();
57
- } catch {
58
- return null;
59
- }
60
- }
51
+ const git = (cwd, args) => gitText(cwd, args, { allowFailure: true });
61
52
 
62
53
  /**
63
54
  * Who last committed here.
@@ -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
- }