simt-dev 0.6.2 → 0.7.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 (170) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +46 -17
  3. package/agents/simt-agent.md +84 -0
  4. package/bin/simt.mjs +19 -6
  5. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  6. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  7. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  8. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  9. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  10. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  11. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  12. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  13. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  14. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  15. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  16. package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
  17. package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
  18. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  19. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  20. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  21. package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
  22. package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
  23. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  24. package/docs/decisions/README.md +53 -0
  25. package/package.json +4 -2
  26. package/rules/core.md +2 -2
  27. package/rules/mdc/preview-postflight.mdc +5 -6
  28. package/rules/mdc/test-gate.mdc +2 -0
  29. package/rules/stacks/android.md +10 -11
  30. package/rules/stacks/cloudflare-workers.md +1 -1
  31. package/rules/stacks/connectiq.md +2 -2
  32. package/rules/stacks/container.md +2 -0
  33. package/rules/stacks/dart.md +1 -1
  34. package/rules/stacks/deno.md +1 -1
  35. package/rules/stacks/dotnet.md +1 -1
  36. package/rules/stacks/elixir.md +1 -1
  37. package/rules/stacks/go.md +1 -1
  38. package/rules/stacks/jvm.md +4 -3
  39. package/rules/stacks/node.md +2 -2
  40. package/rules/stacks/python.md +7 -6
  41. package/rules/stacks/react-native.md +3 -5
  42. package/rules/stacks/react-web.md +9 -9
  43. package/rules/stacks/ruby.md +1 -1
  44. package/rules/stacks/rust.md +1 -1
  45. package/rules/stacks/swift.md +1 -1
  46. package/rules/stacks/typescript.md +2 -2
  47. package/rules/stacks/uv.md +3 -2
  48. package/skills/simt-commands/SKILL.md +70 -0
  49. package/src/advice/advice.mjs +7 -3
  50. package/src/agenthooks/decide.mjs +238 -15
  51. package/src/agenthooks/opencode.mjs +72 -0
  52. package/src/agenthooks/policy.mjs +81 -5
  53. package/src/agenthooks/prompt.mjs +6 -2
  54. package/src/agenthooks/render.mjs +186 -19
  55. package/src/agenthooks/shell.mjs +523 -0
  56. package/src/agenthooks/shim.mjs +55 -0
  57. package/src/agents/render.mjs +277 -0
  58. package/src/argv.mjs +62 -9
  59. package/src/bench/ablate.mjs +31 -14
  60. package/src/bench/mine.mjs +48 -1
  61. package/src/bench/probes.mjs +5 -6
  62. package/src/bench/report.mjs +243 -80
  63. package/src/bench/run.mjs +12 -11
  64. package/src/bench/variants.mjs +112 -40
  65. package/src/brief/assemble.mjs +6 -0
  66. package/src/ci/workflow.mjs +16 -7
  67. package/src/commands/agent.mjs +206 -0
  68. package/src/commands/agenthook.mjs +133 -17
  69. package/src/commands/bench.mjs +2 -1
  70. package/src/commands/ci.mjs +28 -3
  71. package/src/commands/compare.mjs +8 -7
  72. package/src/commands/completions.mjs +27 -0
  73. package/src/commands/decide.mjs +20 -6
  74. package/src/commands/doctor.mjs +367 -0
  75. package/src/commands/evidence.mjs +13 -3
  76. package/src/commands/explain.mjs +52 -0
  77. package/src/commands/gate.mjs +85 -19
  78. package/src/commands/graph.mjs +18 -5
  79. package/src/commands/help.mjs +96 -26
  80. package/src/commands/hooks.mjs +258 -67
  81. package/src/commands/ignore.mjs +152 -0
  82. package/src/commands/impact.mjs +4 -2
  83. package/src/commands/index.mjs +69 -102
  84. package/src/commands/init.mjs +239 -37
  85. package/src/commands/lease.mjs +304 -0
  86. package/src/commands/lessons.mjs +77 -9
  87. package/src/commands/map.mjs +33 -18
  88. package/src/commands/mcp.mjs +2 -2
  89. package/src/commands/pack.mjs +9 -0
  90. package/src/commands/plan.mjs +13 -1
  91. package/src/commands/plugin.mjs +17 -2
  92. package/src/commands/prbody.mjs +45 -0
  93. package/src/commands/repoindex.mjs +4 -0
  94. package/src/commands/rules.mjs +219 -105
  95. package/src/commands/shared.mjs +36 -5
  96. package/src/commands/skills.mjs +329 -206
  97. package/src/commands/skillscompile.mjs +8 -7
  98. package/src/commands/skillsinstall.mjs +112 -30
  99. package/src/commands/slice.mjs +712 -0
  100. package/src/commands/status.mjs +62 -0
  101. package/src/commands/sync.mjs +146 -36
  102. package/src/commands/tests.mjs +5 -2
  103. package/src/commands/undo.mjs +114 -0
  104. package/src/commands/upgrade.mjs +103 -9
  105. package/src/commands/why.mjs +10 -4
  106. package/src/compare/hooksstate.mjs +23 -1
  107. package/src/compare/learn.mjs +82 -57
  108. package/src/compare/parallel.mjs +50 -0
  109. package/src/compare/row.mjs +26 -1
  110. package/src/completions.mjs +196 -0
  111. package/src/decisions/dir.mjs +84 -0
  112. package/src/decisions/index.mjs +109 -11
  113. package/src/diff.mjs +254 -0
  114. package/src/distill/distill.mjs +3 -1
  115. package/src/ecosystem/node-runner.mjs +144 -0
  116. package/src/explain.mjs +270 -0
  117. package/src/federation/config.mjs +44 -0
  118. package/src/graph/load.mjs +52 -24
  119. package/src/hooks/capability.mjs +14 -1
  120. package/src/hooks/dispatcher.mjs +441 -9
  121. package/src/hooks/gates.mjs +14 -3
  122. package/src/hooks/resolve.mjs +23 -3
  123. package/src/hooks/shim.mjs +12 -7
  124. package/src/identity.mjs +67 -14
  125. package/src/ignore.mjs +133 -0
  126. package/src/index/extract.mjs +192 -11
  127. package/src/index/persist.mjs +372 -55
  128. package/src/index/store.mjs +263 -60
  129. package/src/index/tests.mjs +6 -35
  130. package/src/journal.mjs +545 -0
  131. package/src/kernel/card.mjs +37 -12
  132. package/src/kernel/checks.mjs +44 -9
  133. package/src/kernel/variants.mjs +27 -16
  134. package/src/lease.mjs +195 -0
  135. package/src/ledger/index.mjs +91 -25
  136. package/src/mcp/resources.mjs +5 -3
  137. package/src/mcp/server.mjs +86 -13
  138. package/src/mcp/tools.mjs +340 -264
  139. package/src/memory/decide.mjs +98 -38
  140. package/src/memory/lessons.mjs +180 -33
  141. package/src/name.mjs +9 -0
  142. package/src/output/index.mjs +82 -12
  143. package/src/package-version.mjs +49 -1
  144. package/src/packs/manage.mjs +13 -4
  145. package/src/plan/index.mjs +37 -8
  146. package/src/plan/template.mjs +27 -5
  147. package/src/plugins.mjs +33 -8
  148. package/src/prbody.mjs +253 -0
  149. package/src/projection/baselines.mjs +1 -1
  150. package/src/projection/disclosure.mjs +2 -2
  151. package/src/projection/loaders.mjs +41 -1
  152. package/src/projection/lock.mjs +21 -14
  153. package/src/projection/manifest.mjs +82 -21
  154. package/src/projection/markers.mjs +43 -3
  155. package/src/projection/mdc.mjs +34 -7
  156. package/src/projection/project.mjs +162 -18
  157. package/src/projection/rules.mjs +54 -3
  158. package/src/projection/shared.mjs +4 -1
  159. package/src/projection/surfaces.mjs +9 -1
  160. package/src/repo-root.mjs +69 -0
  161. package/src/resolve/body.mjs +4 -0
  162. package/src/resolve/fingerprint.mjs +5 -0
  163. package/src/resolve/local.mjs +58 -0
  164. package/src/review/critic.mjs +4 -2
  165. package/src/secrets.mjs +374 -0
  166. package/src/state.mjs +76 -0
  167. package/src/status.mjs +157 -0
  168. package/src/toolwords.mjs +5 -0
  169. package/src/tsconfig.mjs +39 -99
  170. package/templates/plan/tracker.json +1 -1
@@ -48,14 +48,17 @@ export function fileFor(event) {
48
48
 
49
49
  /**
50
50
  * @param {string} event one of EVENTS
51
- * @param {{npm?: boolean, version?: string}} [opts]
51
+ * @param {{npm?: boolean, manager?: string|null, version?: string}} [opts]
52
52
  * `npm` is whether the repository has a package.json: it decides the remedy's install
53
53
  * (a devDependency where one exists, the release binary into the repository where
54
- * there is no node_modules to pin in). `version` defaults to the running package's own version,
55
- * so a shim generated by 0.2.0 names 0.2.0 in its remedy and CI install.
54
+ * there is no node_modules to pin in). `manager` names the Node manager when one
55
+ * was resolved (Q31: `pnpm add -D`, not `npm i -D`, in a pnpm checkout); it wins
56
+ * over the legacy `npm` boolean so old callers keep their bytes. `version`
57
+ * defaults to the running package's own version, so a shim generated by 0.2.0
58
+ * names 0.2.0 in its remedy and CI install.
56
59
  * @returns {string} POSIX sh source, LF-terminated, executable
57
60
  */
58
- export function renderShim(event, { npm = true, version } = {}) {
61
+ export function renderShim(event, { npm = true, manager = null, version } = {}) {
59
62
  if (!EVENTS.includes(event)) {
60
63
  throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
61
64
  }
@@ -65,7 +68,8 @@ export function renderShim(event, { npm = true, version } = {}) {
65
68
  // installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
66
69
  // so the remedy downloads the release binary into the repository - the same pinned
67
70
  // bytes CI gets, and one that needs no npm and no node on the machine (ADR-098).
68
- const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
71
+ const addDev = { npm: `npm i -D ${PACKAGE_NAME}@${v}`, pnpm: `pnpm add -D ${PACKAGE_NAME}@${v}`, yarn: `yarn add -D ${PACKAGE_NAME}@${v}`, bun: `bun add -d ${PACKAGE_NAME}@${v}` }[manager ?? (npm ? 'npm' : '')];
72
+ const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
69
73
  const D = '$';
70
74
  return `#!/bin/sh
71
75
  ${SHIM_MARKER}
@@ -103,11 +107,12 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
103
107
  * @param {{npm?: boolean, version?: string}} [opts]
104
108
  * @returns {string} POSIX sh source, LF-terminated
105
109
  */
106
- export function renderEvidenceShim({ npm = true, version } = {}) {
110
+ export function renderEvidenceShim({ npm = true, manager = null, version } = {}) {
107
111
  const v = version ?? packageVersion();
108
112
  // The same split as renderShim: a devDependency where one exists, the release
109
113
  // binary into the repository where there is no node_modules to pin in.
110
- const install = npm ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v);
114
+ const addDev = { npm: `npm i -D ${PACKAGE_NAME}@${v}`, pnpm: `pnpm add -D ${PACKAGE_NAME}@${v}`, yarn: `yarn add -D ${PACKAGE_NAME}@${v}`, bun: `bun add -d ${PACKAGE_NAME}@${v}` }[manager ?? (npm ? 'npm' : '')];
115
+ const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
111
116
  const D = '$';
112
117
  return `#!/bin/sh
113
118
  ${SHIM_MARKER}
package/src/identity.mjs CHANGED
@@ -25,10 +25,12 @@
25
25
  // tracked file is not something an adopting command should do silently.
26
26
 
27
27
  import { execFile } from 'node:child_process';
28
- import { readFile, stat } from 'node:fs/promises';
28
+ import { readFile, stat, writeFile } from 'node:fs/promises';
29
29
  import { join } from 'node:path';
30
30
  import { promisify } from 'node:util';
31
31
 
32
+ import { journaledGitConfig, journaledWriteFile } from './journal.mjs';
33
+
32
34
  const exec = promisify(execFile);
33
35
 
34
36
  /** The file, at the repository root. Named for what it is: a git config fragment. */
@@ -166,13 +168,67 @@ export async function includesOwnIdentity(root) {
166
168
  * break. Two of the repositories here had exactly that, and both worked only because
167
169
  * the sibling checkout happened to exist at the expected relative path.
168
170
  *
171
+ * A caller-supplied journal records the key so `undo` restores the routing (R49);
172
+ * without one the write stays unrecorded, as `commitJournal` needs it (review 0003
173
+ * Q30). The journaled path sets through `journaledGitConfig`, which records one
174
+ * key's before and after - the single-value case, and the only shape this tool
175
+ * writes.
176
+ *
169
177
  * @param {string} root
178
+ * @param {Array} [journal]
170
179
  * @returns {Promise<void>}
171
180
  */
172
- export async function includeOwnIdentity(root) {
181
+ export async function includeOwnIdentity(root, journal = null) {
182
+ // A directory that is not a git work tree has no config to route. The lenient
183
+ // `git()` used to swallow the failure here, and an adoption of a plain directory
184
+ // must keep working: there is simply nothing to point at.
185
+ if ((await git(root, ['rev-parse', '--is-inside-work-tree'])) !== 'true') return;
186
+ if (journal) {
187
+ await journaledGitConfig(root, journal, INCLUDE_KEY, `../${IDENTITY_FILE}`);
188
+ return;
189
+ }
173
190
  await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
174
191
  }
175
192
 
193
+ /**
194
+ * Whether .gitignore already names the identity file. The plan needs the answer
195
+ * before anything is written: a line the run does not print is a write the reader
196
+ * approves from the diff afterwards (review 0003 Q30).
197
+ *
198
+ * @param {string} root
199
+ * @returns {Promise<boolean>}
200
+ */
201
+ export async function identityIgnored(root) {
202
+ let text;
203
+ try {
204
+ text = await readFile(join(root, '.gitignore'), 'utf8');
205
+ } catch {
206
+ return false;
207
+ }
208
+ return text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
209
+ }
210
+
211
+ /**
212
+ * The .gitignore bytes with the identity entry appended, or null when it is
213
+ * already there. One owner for the edit's shape: `ignoreIdentity` writes it and
214
+ * `init --diff` previews it, so the two cannot disagree about the bytes (review
215
+ * 0003 Q30).
216
+ *
217
+ * @param {string} text the current bytes, '' when the file is absent
218
+ * @returns {string|null}
219
+ */
220
+ export function gitignoreWithIdentity(text) {
221
+ if (text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE)) return null;
222
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
223
+ return (
224
+ `${text}${prefix}\n` +
225
+ '# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
226
+ '# it carries whoever is committing into the tree, and a repository has more than\n' +
227
+ '# one of those.\n' +
228
+ `${IDENTITY_FILE}\n`
229
+ );
230
+ }
231
+
176
232
  /**
177
233
  * Add the identity file to .gitignore, if it is not already there.
178
234
  *
@@ -181,10 +237,14 @@ export async function includeOwnIdentity(root) {
181
237
  * it means nothing, and appending a duplicate of an entry that is already present
182
238
  * is how a file ends up listed twice.
183
239
  *
240
+ * A caller-supplied journal records the edit so `undo` restores it (R49); without
241
+ * one the write stays unrecorded, as `commitJournal` needs it (review 0003 Q30).
242
+ *
184
243
  * @param {string} root
244
+ * @param {Array} [journal]
185
245
  * @returns {Promise<boolean>} whether the file was changed
186
246
  */
187
- export async function ignoreIdentity(root) {
247
+ export async function ignoreIdentity(root, journal = null) {
188
248
  const path = join(root, '.gitignore');
189
249
  let text;
190
250
  try {
@@ -192,16 +252,9 @@ export async function ignoreIdentity(root) {
192
252
  } catch {
193
253
  text = '';
194
254
  }
195
- const already = text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
196
- if (already) return false;
197
- const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
198
- const block =
199
- `${prefix}\n` +
200
- '# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
201
- '# it carries whoever is committing into the tree, and a repository has more than\n' +
202
- '# one of those.\n' +
203
- `${IDENTITY_FILE}\n`;
204
- const { writeFile } = await import('node:fs/promises');
205
- await writeFile(path, text + block);
255
+ const next = gitignoreWithIdentity(text);
256
+ if (next === null) return false;
257
+ if (journal) await journaledWriteFile(root, journal, path, next);
258
+ else await writeFile(path, next);
206
259
  return true;
207
260
  }
package/src/ignore.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // One ignore list, four readers' dialects (plan 0003 R52).
2
+ //
3
+ // Cursor, Aider, Gemini and Claude each want the same fact — "the agent must
4
+ // not load these paths" — in a different place: three gitignore-style files and
5
+ // one array inside a JSON settings object. Hand-maintaining four copies is four
6
+ // chances to forget one, and a forget is silent: the agent reads the file that
7
+ // did get updated and confidently loads the path the other three excluded. So
8
+ // the list lives in exactly one file (`.simtignore`, at the repository root,
9
+ // where a gitignore-syntax list already belongs) and everything else is
10
+ // rendered from it.
11
+ //
12
+ // Two ownership rules, because the four targets are not equally ours:
13
+ //
14
+ // - The three ignore files are GENERATED: whatever they held is what the list
15
+ // says after `simt ignore --yes`. A line that is in one and not in the list
16
+ // is drift between four files that claim to agree, and the plan prints it
17
+ // as a removal before anything is written.
18
+ // - `.claude/settings.json` is the READER'S file that merely contains our
19
+ // entries. We merge: every key, every permission and every deny entry that
20
+ // was already there survives byte-for-byte, and we only add `Read(...)`
21
+ // lines. In particular we never REMOVE a deny entry, because deleting a
22
+ // permission is a security decision that belongs to whoever wrote it — an
23
+ // entry dropped from `.simtignore` leaves a stale deny in Claude (safe
24
+ // direction) while the three generated files converge immediately.
25
+
26
+ /** The one list. gitignore syntax: one path or glob per line, `#` comments. */
27
+ export const IGNORE_FILE = '.simtignore';
28
+
29
+ /** The three readers that take the list verbatim. */
30
+ const TEXT_TARGETS = ['.cursorignore', '.aiderignore', '.geminiignore'];
31
+
32
+ /** The reader that takes it as `permissions.deny` `Read(...)` entries. */
33
+ export const CLAUDE_SETTINGS = '.claude/settings.json';
34
+
35
+ /**
36
+ * Parse the list: trim, drop blanks and `#` comments, keep entries verbatim.
37
+ *
38
+ * Verbatim matters — `.env.local` and `secrets/` and `*.pem` are the reader's
39
+ * globs, and normalising them would be this tool guessing at another tool's
40
+ * pattern language.
41
+ */
42
+ export function parseIgnoreList(text) {
43
+ return String(text)
44
+ .split('\n')
45
+ .map((line) => line.replace(/\r$/, '').trim())
46
+ .filter((line) => line !== '' && !line.startsWith('#'));
47
+ }
48
+
49
+ /** The bytes of one of the three generated ignore files. */
50
+ export function renderTextIgnore(entries) {
51
+ return entries.length === 0 ? '' : `${entries.join('\n')}\n`;
52
+ }
53
+
54
+ /** The list as Claude deny entries: `Read(.env)`, `Read(secrets/**)`, … */
55
+ export function denyEntries(entries) {
56
+ return entries.map((entry) => `Read(${entry})`);
57
+ }
58
+
59
+ /**
60
+ * Merge the list into `.claude/settings.json`, preserving everything else.
61
+ *
62
+ * @param {string|null} existingText current bytes, or null when absent
63
+ * @param {string[]} entries the parsed list
64
+ * @returns {{ok: true, text: string} | {ok: false, error: string}} — a settings
65
+ * file that is not valid JSON OBJECT is refused rather than replaced: the one
66
+ * failure that loses a reader's whole Claude configuration is a renderer that
67
+ * "repairs" what it could not parse.
68
+ */
69
+ export function renderClaudeSettings(existingText, entries) {
70
+ let settings = {};
71
+ if (existingText !== null) {
72
+ try {
73
+ settings = JSON.parse(existingText);
74
+ } catch (error) {
75
+ return { ok: false, error: `${CLAUDE_SETTINGS} is not valid JSON: ${error.message}` };
76
+ }
77
+ if (settings === null || typeof settings !== 'object' || Array.isArray(settings)) {
78
+ return { ok: false, error: `${CLAUDE_SETTINGS} is not a JSON object; it cannot hold permissions` };
79
+ }
80
+ }
81
+
82
+ const current =
83
+ settings.permissions !== null &&
84
+ typeof settings.permissions === 'object' &&
85
+ !Array.isArray(settings.permissions)
86
+ ? { ...settings.permissions }
87
+ : {};
88
+ const existing = Array.isArray(current.deny)
89
+ ? current.deny.filter((entry) => typeof entry === 'string')
90
+ : [];
91
+ const wanted = denyEntries(entries);
92
+ // Existing entries keep their order and their bytes; ours append in list
93
+ // order, deduplicated so a re-run is byte-idempotent.
94
+ current.deny = [...existing, ...wanted.filter((entry) => !existing.includes(entry))];
95
+ settings.permissions = current;
96
+
97
+ return { ok: true, text: `${JSON.stringify(settings, null, 2)}\n` };
98
+ }
99
+
100
+ /**
101
+ * The four targets with their desired bytes, from the current bytes of each.
102
+ *
103
+ * @param {string[]} entries the parsed list
104
+ * @param {(path: string) => Promise<string|null>} read current bytes or null
105
+ * @returns {Promise<{ok: true, targets: {path: string, action: 'create'|'update'|'unchanged',
106
+ * before: string|null, after: string}[]} | {ok: false, error: string}>}
107
+ */
108
+ export async function planIgnore(entries, read) {
109
+ const text = renderTextIgnore(entries);
110
+ const targets = [];
111
+
112
+ for (const path of TEXT_TARGETS) {
113
+ const before = await read(path);
114
+ targets.push({
115
+ path,
116
+ action: before === null ? 'create' : before === text ? 'unchanged' : 'update',
117
+ before,
118
+ after: text,
119
+ });
120
+ }
121
+
122
+ const claudeBefore = await read(CLAUDE_SETTINGS);
123
+ const merged = renderClaudeSettings(claudeBefore, entries);
124
+ if (!merged.ok) return { ok: false, error: merged.error };
125
+ targets.push({
126
+ path: CLAUDE_SETTINGS,
127
+ action: claudeBefore === null ? 'create' : claudeBefore === merged.text ? 'unchanged' : 'update',
128
+ before: claudeBefore,
129
+ after: merged.text,
130
+ });
131
+
132
+ return { ok: true, targets };
133
+ }
@@ -51,13 +51,19 @@ export function extract(filePath, text, lang) {
51
51
  return { symbols, imports: found.imports };
52
52
  }
53
53
 
54
- /** Import specifiers become edges. A relative one points at a tracked file when one matches. */
54
+ /**
55
+ * Import specifiers become edges. A relative one points at a tracked file when
56
+ * one matches; a non-relative one goes through the language's namespace
57
+ * strategy below (review 0003 Q40).
58
+ */
55
59
  export function resolveImports(filePath, imports, files, lang) {
56
60
  const have = files instanceof Set ? files : new Set(files);
61
+ const stems = indexByStem(have);
62
+ const dirs = lang === 'go' || lang === 'csharp' ? indexDirs(have, lang === 'go' ? '.go' : '.cs') : null;
57
63
  const edges = [];
58
64
  const seen = new Set();
59
65
  for (const spec of imports) {
60
- const dst = resolveOne(filePath, spec, have, lang);
66
+ const dst = resolveOne(filePath, spec, have, stems, dirs, lang);
61
67
  if (seen.has(dst)) continue;
62
68
  seen.add(dst);
63
69
  edges.push({ src: filePath, dst, kind: 'import', weight: 1 });
@@ -66,9 +72,142 @@ export function resolveImports(filePath, imports, files, lang) {
66
72
  return edges;
67
73
  }
68
74
 
69
- function resolveOne(filePath, spec, files, lang) {
75
+ /** Stem (basename) to tracked paths, so a namespace lookup touches its stem group, not the tree. */
76
+ function indexByStem(have) {
77
+ const map = new Map();
78
+ for (const file of have) {
79
+ const stem = file.slice(file.lastIndexOf('/') + 1);
80
+ const list = map.get(stem);
81
+ if (list) list.push(file);
82
+ else map.set(stem, [file]);
83
+ }
84
+ return map;
85
+ }
86
+
87
+ /** Directory to files of one extension, for the languages whose imports name a package, not a file. */
88
+ function indexDirs(have, ext) {
89
+ const map = new Map();
90
+ for (const file of have) {
91
+ if (!file.endsWith(ext)) continue;
92
+ const dir = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '';
93
+ const list = map.get(dir);
94
+ if (list) list.push(file);
95
+ else map.set(dir, [file]);
96
+ }
97
+ return map;
98
+ }
99
+
100
+ /**
101
+ * One strategy per namespace language (review 0003 Q40).
102
+ *
103
+ * Outside JS/TS, imports name namespaces: `com.foo.Bar`, `pkg.mod`,
104
+ * `example.com/mod/pkg`, `crate::a::b`, `A.B.C`. Each builder maps the spec
105
+ * to ordered path candidates; the first candidate with exactly one tracked hit
106
+ * wins, several hits stay the raw specifier (ambiguous is not resolved), and
107
+ * none keeps the old behaviour. Languages whose imports cannot name a file -
108
+ * a C# `using` names a namespace shared by many files, a Go import names a
109
+ * package - resolve only the single-file case and keep the rest as specs.
110
+ */
111
+ function nonRelativeCandidates(spec, lang) {
112
+ if (lang === 'kotlin' || lang === 'java') {
113
+ const parts = spec.split('.');
114
+ if (parts.length < 2 || parts.some((p) => p === '')) return null;
115
+ return dottedFileCandidates(parts, lang === 'kotlin' ? ['.kt', '.kts'] : ['.java'], null);
116
+ }
117
+ if (lang === 'python') {
118
+ const parts = spec.split('.');
119
+ if (parts.some((p) => p === '')) return null;
120
+ const base = parts.join('/');
121
+ return [`${base}.py`, `${base}/__init__.py`];
122
+ }
123
+ if (lang === 'rust') {
124
+ const parts = spec.split('::');
125
+ if (parts[0] === 'crate' || parts[0] === 'self' || parts[0] === 'super') parts.shift();
126
+ else if (parts[0] === '') return null;
127
+ if (parts.length === 0 || parts.some((p) => p === '')) return null;
128
+ return dottedFileCandidates(parts, ['.rs'], 'mod.rs');
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Full dotted path first, then dropping a trailing Uppercase segment: a nested
135
+ * type lives in its outer file (`com.foo.Outer.Inner` → `Outer.kt`). The drop
136
+ * stops at a lowercase segment, so `java.util.List` never becomes `util.java` -
137
+ * a stdlib import must not resolve to a coincidental file of that name.
138
+ */
139
+ function dottedFileCandidates(parts, exts, modFile) {
140
+ const out = [];
141
+ const segs = [...parts];
142
+ for (;;) {
143
+ const base = segs.join('/');
144
+ for (const ext of exts) out.push(`${base}${ext}`);
145
+ if (modFile) out.push(`${base}/${modFile}`);
146
+ const last = segs[segs.length - 1] ?? '';
147
+ const outer = segs[segs.length - 2] ?? '';
148
+ if (segs.length < 2 || !/^[A-Z]/.test(last) || (segs.length > 2 && !/^[A-Z]/.test(outer))) break;
149
+ segs.pop();
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** First candidate with exactly one tracked hit wins; several hits are ambiguous, not resolved. */
155
+ function suffixHit(candidates, have, stems) {
156
+ for (const candidate of candidates) {
157
+ if (have.has(candidate)) return candidate;
158
+ const stem = candidate.slice(candidate.lastIndexOf('/') + 1);
159
+ const hits = (stems.get(stem) ?? []).filter((f) => f === candidate || f.endsWith(`/${candidate}`));
160
+ if (hits.length === 1) return hits[0];
161
+ if (hits.length > 1) return null;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * A package import resolves to its directory's single file, and only then.
168
+ *
169
+ * Stripping from the left is the go.mod-prefix strategy without reading go.mod:
170
+ * whatever the module prefix is, it is the part that does not match. A package
171
+ * of several files has no single answer a file edge could name, so it stays a
172
+ * specifier rather than pointing at an arbitrary member.
173
+ */
174
+ function dirHit(spec, dirs) {
175
+ const parts = spec.replace(/\\/g, '/').replace(/^\.\//, '').split('/').filter((p) => p !== '');
176
+ for (let i = 0; i < parts.length; i += 1) {
177
+ const suffix = parts.slice(i).join('/');
178
+ const found = [];
179
+ for (const [dir, members] of dirs) {
180
+ if (dir === suffix || (suffix !== '' && dir.endsWith(`/${suffix}`))) found.push(...members);
181
+ }
182
+ if (found.length === 1) return found[0];
183
+ if (found.length > 1) return null;
184
+ }
185
+ return null;
186
+ }
187
+
188
+ /**
189
+ * An explicit extension that names the sibling language (review 0003 Q41).
190
+ *
191
+ * TypeScript sources import their compiled shape (`./b.js` for `b.ts`), and
192
+ * Node's ESM resolution requires it; the tracked tree holds the source, so
193
+ * the edge must try the source extension before giving up and storing the
194
+ * specifier raw.
195
+ */
196
+ const EXT_SWAPS = { '.js': ['.ts', '.tsx'], '.mjs': ['.mts'], '.cjs': ['.cts'] };
197
+
198
+ function resolveOne(filePath, spec, files, stems, dirs, lang) {
70
199
  const rel = relativeSpec(spec);
71
- if (rel === null) return spec;
200
+ if (rel === null) {
201
+ if (lang === 'go' || lang === 'csharp') {
202
+ // Namespace-as-directory: `A.B.C` and `example.com/mod/pkg` use dots and
203
+ // slashes interchangeably for what is a directory on disk.
204
+ const dotted = lang === 'csharp' ? String(spec).replace(/\\/g, '/').split('.').join('/') : String(spec);
205
+ return dirHit(dotted, dirs) ?? spec;
206
+ }
207
+ const candidates = nonRelativeCandidates(String(spec), lang);
208
+ if (candidates === null) return spec;
209
+ return suffixHit(candidates, files, stems) ?? spec;
210
+ }
72
211
  const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
73
212
  const base = slash(dir, rel);
74
213
  const candidates = [base];
@@ -82,6 +221,13 @@ function resolveOne(filePath, spec, files, lang) {
82
221
  for (const candidate of candidates) {
83
222
  if (files.has(candidate)) return candidate;
84
223
  }
224
+ for (const [from, tos] of Object.entries(EXT_SWAPS)) {
225
+ if (!base.endsWith(from)) continue;
226
+ for (const to of tos) {
227
+ const swapped = base.slice(0, -from.length) + to;
228
+ if (files.has(swapped)) return swapped;
229
+ }
230
+ }
85
231
  return spec;
86
232
  }
87
233
 
@@ -146,14 +292,48 @@ function scan(text, onLine) {
146
292
  return { symbols, imports };
147
293
  }
148
294
 
295
+ /**
296
+ * Import specifiers in a script, including an import that spans lines.
297
+ *
298
+ * Single-line regexes missed the case the review caught (Q41): an import whose
299
+ * `from` lands several lines below the `import`. Buffering until the closing
300
+ * quote (or `;`) sees the whole statement before deciding what it names.
301
+ * Moved here from `tests.mjs` so the index and the test mapping read the same
302
+ * bytes; `tests.mjs` re-exports it, so its importers do not move.
303
+ */
304
+ export function scriptSpecifiers(text) {
305
+ const specs = [];
306
+ const lines = String(text).split('\n');
307
+ let buf = '';
308
+ let open = false;
309
+ for (const line of lines) {
310
+ const trimmed = line.trim();
311
+ if (!open) {
312
+ if (!/^(?:import|export)\b/.test(trimmed)) continue;
313
+ // A declaration is not an import. Capturing it would swallow the next
314
+ // import's `from` and report a specifier the file does not have.
315
+ if (/^export\s+(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\b/.test(trimmed) && !trimmed.includes(' from ')) continue;
316
+ buf = trimmed;
317
+ open = true;
318
+ } else {
319
+ buf += `\n${trimmed}`;
320
+ }
321
+ const done = /\bfrom\s+['"][^'"]+['"]/.test(buf) || /^import\s+['"][^'"]+['"]/.test(buf) || buf.includes(';') || buf.length > 2000;
322
+ if (!done) continue;
323
+ const from = buf.match(/\bfrom\s+['"]([^'"]+)['"]/);
324
+ const side = buf.match(/^import\s+['"]([^'"]+)['"]/);
325
+ if (from) specs.push(from[1]);
326
+ else if (side) specs.push(side[1]);
327
+ buf = '';
328
+ open = false;
329
+ }
330
+ for (const match of String(text).matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
331
+ for (const match of String(text).matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g)) specs.push(match[1]);
332
+ return [...new Set(specs)];
333
+ }
334
+
149
335
  function script(text) {
150
- return scan(text, (line, raw, lineNo, symbols, imports) => {
151
- const from = line.match(/^(?:import|export)\s[\s\S]*?\sfrom\s+['"]([^'"]+)['"]/);
152
- const side = line.match(/^import\s+['"]([^'"]+)['"]/);
153
- const req = line.match(/\brequire\(\s*['"]([^'"]+)['"]\s*\)/);
154
- if (from) imports.push(from[1]);
155
- else if (side) imports.push(side[1]);
156
- else if (req) imports.push(req[1]);
336
+ const { symbols } = scan(text, (line, raw, lineNo, symbols) => {
157
337
  let m;
158
338
  if ((m = line.match(new RegExp(`^export\\s+(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*(${IDENT})`)))) {
159
339
  add(symbols, m[1], 'function', lineNo, raw, 1);
@@ -179,6 +359,7 @@ function script(text) {
179
359
  add(symbols, m[1], 'class', lineNo, raw, line.startsWith('export ') ? 1 : 0);
180
360
  }
181
361
  });
362
+ return { symbols, imports: scriptSpecifiers(text) };
182
363
  }
183
364
 
184
365
  const KT_MOD = '(?:public|private|internal|protected|open|data|sealed|abstract|enum|inner|value|annotation|suspend|override|operator|inline)';