simt-dev 0.6.3 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- 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
- 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
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- 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
- 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
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- 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
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/hooks/gates.mjs
CHANGED
|
@@ -105,49 +105,6 @@ export function getGate(name) {
|
|
|
105
105
|
return GATES[name] ?? null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/**
|
|
109
|
-
* A gate a repo cannot run is a FAILURE, never a skip.
|
|
110
|
-
*
|
|
111
|
-
* This is the load-bearing decision of the whole dispatcher. A gate that
|
|
112
|
-
* silently does nothing is indistinguishable from a gate that passed, and the
|
|
113
|
-
* documented consequence in this estate is an agent reaching for
|
|
114
|
-
* `--no-verify` - a repository removed its tests from pre-commit precisely
|
|
115
|
-
* because they were slow, and slow-plus-silent is what teaches everyone that
|
|
116
|
-
* hooks are advisory.
|
|
117
|
-
*/
|
|
118
|
-
export function unresolved(name, { hasScript, binary, command, stack }) {
|
|
119
|
-
const gate = getGate(name);
|
|
120
|
-
if (gate === null) {
|
|
121
|
-
return {
|
|
122
|
-
gate: name,
|
|
123
|
-
reason: `unknown gate; the shared dispatcher knows: ${Object.keys(GATES).sort().join(', ')}`,
|
|
124
|
-
remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
if (gate.kind === 'script' && !hasScript(gate.script)) {
|
|
128
|
-
return {
|
|
129
|
-
gate: name,
|
|
130
|
-
reason: `no "${gate.script}" script in package.json`,
|
|
131
|
-
remedy: `add the script, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
if (gate.kind === 'command' && !command) {
|
|
135
|
-
return {
|
|
136
|
-
gate: name,
|
|
137
|
-
reason: `no command declared for this gate in ${MANIFEST_FILE}`,
|
|
138
|
-
remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
if (gate.kind === 'staged' && !binary) {
|
|
142
|
-
return {
|
|
143
|
-
gate: name,
|
|
144
|
-
reason: `${gate.tools.join('/')} is not installed in node_modules/.bin`,
|
|
145
|
-
remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
108
|
export function gateNames() {
|
|
152
109
|
return Object.keys(GATES).sort();
|
|
153
110
|
}
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -14,14 +14,9 @@ import { access } from 'node:fs/promises';
|
|
|
14
14
|
import { constants } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { FORMATTABLE, GATES } from './gates.mjs';
|
|
17
|
+
import { FORMATTABLE, GATES, gateNames } from './gates.mjs';
|
|
18
18
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
19
19
|
|
|
20
|
-
/** Gate names the shared dispatcher knows, sorted. */
|
|
21
|
-
export function knownGates() {
|
|
22
|
-
return Object.keys(GATES).sort();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
/** Is this path an executable file? */
|
|
26
21
|
async function isExecutable(path) {
|
|
27
22
|
try {
|
|
@@ -68,7 +63,7 @@ export function hasScript(pkg, script) {
|
|
|
68
63
|
* @returns {Promise<{ok: true, argv: string[], kind: string, detail: string}
|
|
69
64
|
* | {ok: false, gate: string, kind: string, reason: string, remedy: string}>}
|
|
70
65
|
*/
|
|
71
|
-
export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
66
|
+
export async function resolveGate(gate, { root, pkg, staged, commands, runner = null }) {
|
|
72
67
|
const name = gate.name;
|
|
73
68
|
const declared = commands?.[name];
|
|
74
69
|
|
|
@@ -172,11 +167,17 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
|
172
167
|
remedy: `add "${script}" to scripts, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
173
168
|
};
|
|
174
169
|
}
|
|
170
|
+
// Q31: script gates run through the repo's own manager — `pnpm run`, not
|
|
171
|
+
// `npm run`, in a pnpm checkout. The default stays npm for callers that do
|
|
172
|
+
// not report one (older capabilities, hand-built contexts).
|
|
173
|
+
const { runScriptArgv, runScript } = await import('../ecosystem/node-runner.mjs');
|
|
174
|
+
const manager = runner ?? pkg?.packageManager?.split('@')?.[0] ?? 'npm';
|
|
175
|
+
const known = ['npm', 'pnpm', 'yarn', 'bun'].includes(manager) ? manager : 'npm';
|
|
175
176
|
return {
|
|
176
177
|
ok: true,
|
|
177
178
|
kind: gate.kind,
|
|
178
|
-
argv:
|
|
179
|
-
detail:
|
|
179
|
+
argv: runScriptArgv(known, script),
|
|
180
|
+
detail: runScript(known, script),
|
|
180
181
|
};
|
|
181
182
|
}
|
|
182
183
|
|
|
@@ -197,7 +198,7 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
|
197
198
|
gate: name,
|
|
198
199
|
kind: 'unknown',
|
|
199
200
|
reason: `unknown gate kind "${gate.kind}"`,
|
|
200
|
-
remedy: `this is a bug in simt-dev;
|
|
201
|
+
remedy: `this is a bug in simt-dev; )}', ')}`,
|
|
201
202
|
};
|
|
202
203
|
}
|
|
203
204
|
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -36,8 +36,11 @@ export const SHIM_MARKER = '# Generated by simt-dev. Do not edit.';
|
|
|
36
36
|
/** Events the shared dispatcher knows how to run. */
|
|
37
37
|
export const EVENTS = ['pre-commit', 'pre-push'];
|
|
38
38
|
|
|
39
|
-
/** Directory the shims are written to inside a repo. */
|
|
40
|
-
export const HOOKS_DIR = '.simt
|
|
39
|
+
/** Directory the shims are written to inside a repo, inside the state directory (ADR-122). */
|
|
40
|
+
export const HOOKS_DIR = '.simt/hooks';
|
|
41
|
+
|
|
42
|
+
/** The top-level shim directory that preceded the move into the state directory (ADR-122). */
|
|
43
|
+
export const HOOKS_DIR_LEGACY = '.simt-hooks';
|
|
41
44
|
|
|
42
45
|
/** Name of the git config key that points at the shim directory. */
|
|
43
46
|
export const HOOKS_PATH_KEY = 'core.hooksPath';
|
|
@@ -48,14 +51,17 @@ export function fileFor(event) {
|
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
53
|
* @param {string} event one of EVENTS
|
|
51
|
-
* @param {{npm?: boolean, version?: string}} [opts]
|
|
54
|
+
* @param {{npm?: boolean, manager?: string|null, version?: string}} [opts]
|
|
52
55
|
* `npm` is whether the repository has a package.json: it decides the remedy's install
|
|
53
56
|
* (a devDependency where one exists, the release binary into the repository where
|
|
54
|
-
* there is no node_modules to pin in). `
|
|
55
|
-
*
|
|
57
|
+
* there is no node_modules to pin in). `manager` names the Node manager when one
|
|
58
|
+
* was resolved (Q31: `pnpm add -D`, not `npm i -D`, in a pnpm checkout); it wins
|
|
59
|
+
* over the legacy `npm` boolean so old callers keep their bytes. `version`
|
|
60
|
+
* defaults to the running package's own version, so a shim generated by 0.2.0
|
|
61
|
+
* names 0.2.0 in its remedy and CI install.
|
|
56
62
|
* @returns {string} POSIX sh source, LF-terminated, executable
|
|
57
63
|
*/
|
|
58
|
-
export function renderShim(event, { npm = true, version } = {}) {
|
|
64
|
+
export function renderShim(event, { npm = true, manager = null, version } = {}) {
|
|
59
65
|
if (!EVENTS.includes(event)) {
|
|
60
66
|
throw new Error(`unknown hook event ${event}; known: ${EVENTS.join(', ')}`);
|
|
61
67
|
}
|
|
@@ -65,7 +71,8 @@ export function renderShim(event, { npm = true, version } = {}) {
|
|
|
65
71
|
// installs it there; one without (Gradle, Connect IQ) has no node_modules to pin in,
|
|
66
72
|
// so the remedy downloads the release binary into the repository - the same pinned
|
|
67
73
|
// bytes CI gets, and one that needs no npm and no node on the machine (ADR-098).
|
|
68
|
-
const
|
|
74
|
+
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' : '')];
|
|
75
|
+
const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
|
|
69
76
|
const D = '$';
|
|
70
77
|
return `#!/bin/sh
|
|
71
78
|
${SHIM_MARKER}
|
|
@@ -103,11 +110,12 @@ ${renderLocator(`hooks run ${event} --root "${D}ROOT"`, {
|
|
|
103
110
|
* @param {{npm?: boolean, version?: string}} [opts]
|
|
104
111
|
* @returns {string} POSIX sh source, LF-terminated
|
|
105
112
|
*/
|
|
106
|
-
export function renderEvidenceShim({ npm = true, version } = {}) {
|
|
113
|
+
export function renderEvidenceShim({ npm = true, manager = null, version } = {}) {
|
|
107
114
|
const v = version ?? packageVersion();
|
|
108
115
|
// The same split as renderShim: a devDependency where one exists, the release
|
|
109
116
|
// binary into the repository where there is no node_modules to pin in.
|
|
110
|
-
const
|
|
117
|
+
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' : '')];
|
|
118
|
+
const install = addDev ?? (npm && !manager ? `npm i -D ${PACKAGE_NAME}@${v}` : localInstall(v));
|
|
111
119
|
const D = '$';
|
|
112
120
|
return `#!/bin/sh
|
|
113
121
|
${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,37 @@ export async function ignoreIdentity(root) {
|
|
|
192
252
|
} catch {
|
|
193
253
|
text = '';
|
|
194
254
|
}
|
|
195
|
-
const
|
|
196
|
-
if (
|
|
197
|
-
|
|
198
|
-
|
|
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
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The `user.*` keys the repository's own config sets, which is the one place an
|
|
263
|
+
* identity can be overridden without a trace in the tracked tree.
|
|
264
|
+
*
|
|
265
|
+
* A repo-local `[user]` block wins over both the `git.config` include and the
|
|
266
|
+
* machine's own identity, so every commit made here inherits it silently - including
|
|
267
|
+
* the commits this tool makes on the reader's behalf (`slice finish`, `plan done`).
|
|
268
|
+
* That is not a hypothetical: 34 commits in this repository were authored as
|
|
269
|
+
* `t <t@t.t>` because `.git/config` carried that block and nothing reported it. The
|
|
270
|
+
* helper exists so `test/repo-identity.test.mjs` can fail loudly instead.
|
|
271
|
+
*
|
|
272
|
+
* `--local` rather than `--get`: the question is what THIS config adds, not what the
|
|
273
|
+
* resolved identity happens to be.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} root
|
|
276
|
+
* @returns {Promise<{key: string, value: string}[]>}
|
|
277
|
+
*/
|
|
278
|
+
export async function localIdentityOverrides(root) {
|
|
279
|
+
const out = await git(root, ['config', '--local', '--get-regexp', '^user\\.']);
|
|
280
|
+
if (!out) return [];
|
|
281
|
+
return out
|
|
282
|
+
.split('\n')
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
.map((line) => {
|
|
285
|
+
const space = line.indexOf(' ');
|
|
286
|
+
return { key: line.slice(0, space), value: line.slice(space + 1) };
|
|
287
|
+
});
|
|
288
|
+
}
|
package/src/index/extract.mjs
CHANGED
|
@@ -51,13 +51,19 @@ export function extract(filePath, text, lang) {
|
|
|
51
51
|
return { symbols, imports: found.imports };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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)';
|