simt-dev 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.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-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/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
package/src/journal.mjs
CHANGED
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
// - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
|
|
17
17
|
// rename (the same stale spelling survives in bench/probes.mjs comments).
|
|
18
18
|
// The journal lives under STATE_DIR like every other per-repo state.
|
|
19
|
-
// - Nothing under `.simt/` is journaled
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// repair anything.
|
|
19
|
+
// - Nothing under `.simt/` is journaled, one named exception: the shim
|
|
20
|
+
// directory. The journal recording itself is recursion; the ledger, index
|
|
21
|
+
// and candidates are evidence and caches, and restoring those to an earlier
|
|
22
|
+
// byte-shape would corrupt R17 rather than repair anything. The shims are
|
|
23
|
+
// the opposite of that: git's routing points at them, and the undo that
|
|
24
|
+
// restores the routing must restore the shims with it (review 0003 Q22;
|
|
25
|
+
// ADR-122 moved them under STATE_DIR). `journaledWriteFile` performs the
|
|
26
|
+
// other writes unrecorded.
|
|
23
27
|
// - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
|
|
24
28
|
// the record shape is file bytes — pointer changes are reported by the
|
|
25
29
|
// command as before and are not undone. Removals are journaled as entries
|
|
@@ -37,16 +41,15 @@
|
|
|
37
41
|
// a second `undo` naturally targets the set before it, and the untouched-check
|
|
38
42
|
// refuses when the two sets overlap on a file the first undo already restored.
|
|
39
43
|
|
|
40
|
-
import { execFile } from 'node:child_process';
|
|
41
44
|
import { createHash } from 'node:crypto';
|
|
42
|
-
import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
45
|
+
import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
43
46
|
import { dirname, join, relative, sep } from 'node:path';
|
|
44
|
-
import { promisify } from 'node:util';
|
|
45
47
|
|
|
48
|
+
import { noteEvent } from './state/events.mjs';
|
|
46
49
|
import { STATE_DIR } from './name.mjs';
|
|
47
50
|
import { LOCAL_DIR } from './state.mjs';
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
import { HOOKS_DIR } from './hooks/shim.mjs';
|
|
52
|
+
import { git, gitText } from './sys/git.mjs';
|
|
50
53
|
|
|
51
54
|
const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
|
|
52
55
|
const JOURNAL_DIR_LEGACY = '.simt/journal';
|
|
@@ -129,8 +132,11 @@ export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
|
|
|
129
132
|
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
130
133
|
}
|
|
131
134
|
const before = await readOrNull(path);
|
|
132
|
-
// Internal state is written, not recorded — see the module header
|
|
133
|
-
|
|
135
|
+
// Internal state is written, not recorded — see the module header, which
|
|
136
|
+
// names the shim directory as the one exception under STATE_DIR.
|
|
137
|
+
const r = rel(root, path);
|
|
138
|
+
const internal =
|
|
139
|
+
(r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
|
|
134
140
|
await mkdir(dirname(path), { recursive: true });
|
|
135
141
|
// tmp + rename, the way this module writes its own set files: a crash
|
|
136
142
|
// mid-write leaves the old bytes in place, never a half-written file
|
|
@@ -202,7 +208,9 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
202
208
|
}
|
|
203
209
|
const before = await readOrNull(path);
|
|
204
210
|
if (before === null) return;
|
|
205
|
-
const
|
|
211
|
+
const r = rel(root, path);
|
|
212
|
+
const internal =
|
|
213
|
+
(r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
|
|
206
214
|
await unlink(path);
|
|
207
215
|
if (!internal) {
|
|
208
216
|
acc.push(fileEntry(rel(root, path), before, null));
|
|
@@ -212,8 +220,7 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
212
220
|
/** Read one git config value in a repo, or null when unset. */
|
|
213
221
|
export async function readGitValue(root, key) {
|
|
214
222
|
try {
|
|
215
|
-
|
|
216
|
-
return stdout.replace(/\n$/, '');
|
|
223
|
+
return await gitText(root, ['config', '--get', key]);
|
|
217
224
|
} catch (error) {
|
|
218
225
|
if (error.code === 1) return null;
|
|
219
226
|
throw error;
|
|
@@ -236,9 +243,9 @@ export async function journaledGitConfig(root, acc, key, value) {
|
|
|
236
243
|
const before = await readGitValue(root, key);
|
|
237
244
|
if (before === value) return null;
|
|
238
245
|
if (value === null) {
|
|
239
|
-
await
|
|
246
|
+
await git(root, ['config', '--unset', key]);
|
|
240
247
|
} else {
|
|
241
|
-
await
|
|
248
|
+
await git(root, ['config', key, value]);
|
|
242
249
|
}
|
|
243
250
|
const entry = { kind: 'git-config', key, before, after: value };
|
|
244
251
|
acc.push(entry);
|
|
@@ -324,6 +331,61 @@ function coalesceEntries(acc) {
|
|
|
324
331
|
});
|
|
325
332
|
}
|
|
326
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Move the sets that predate the move into `.simt/local/journal` (review 0003
|
|
336
|
+
* Q19's fallback directory) into the current directory, and take the legacy
|
|
337
|
+
* directory down when the move empties it. While the current directory is
|
|
338
|
+
* absent, `listJournalSets` falls back to the legacy one; once it exists, the
|
|
339
|
+
* two are disjoint and the legacy sets are invisible to `undo` — a repo that
|
|
340
|
+
* accumulated history under the old path loses it on its first write after the
|
|
341
|
+
* move (plan 0005 H3). `commitJournal` is the only writer, so the migration
|
|
342
|
+
* rides on it and heals on the next journal activity. Every legacy set
|
|
343
|
+
* predates every current set — the current directory is created by this
|
|
344
|
+
* writer — so the move cannot disturb recency order. A name present in both
|
|
345
|
+
* keeps both copies when the bytes differ: a set file is history, and a
|
|
346
|
+
* collision is not a reason to drop one of the two records. A crash mid-move
|
|
347
|
+
* splits the sets between the two directories; the move is idempotent, so the
|
|
348
|
+
* next call finishes it.
|
|
349
|
+
*/
|
|
350
|
+
async function migrateLegacyJournal(root) {
|
|
351
|
+
const legacy = join(root, JOURNAL_DIR_LEGACY);
|
|
352
|
+
let names;
|
|
353
|
+
try {
|
|
354
|
+
names = await readdir(legacy);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error.code !== 'ENOENT') throw error;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const dir = join(root, JOURNAL_DIR);
|
|
360
|
+
await mkdir(dir, { recursive: true });
|
|
361
|
+
for (const name of names) {
|
|
362
|
+
// Sets, `.undone.json` history and `.undoing.json` markers all end in
|
|
363
|
+
// `.json`; a stray file of any other kind keeps the directory in place.
|
|
364
|
+
if (!name.endsWith(SET_SUFFIX)) continue;
|
|
365
|
+
const from = join(legacy, name);
|
|
366
|
+
const to = join(dir, name);
|
|
367
|
+
try {
|
|
368
|
+
await stat(to);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error.code !== 'ENOENT') throw error;
|
|
371
|
+
await rename(from, to);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
// The name is taken: equal bytes are a duplicate of the same set (drop the
|
|
375
|
+
// legacy copy); differing bytes are a collision (keep both — the current
|
|
376
|
+
// copy shadows the legacy one in the listing).
|
|
377
|
+
const [a, b] = await Promise.all([readFile(from), readFile(to)]);
|
|
378
|
+
if (a.equals(b)) await unlink(from);
|
|
379
|
+
}
|
|
380
|
+
// `rmdir` fails when the directory is not empty, which is exactly the
|
|
381
|
+
// safety: a stray file the move does not take keeps the directory.
|
|
382
|
+
try {
|
|
383
|
+
await rmdir(legacy);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST') throw error;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
327
389
|
/**
|
|
328
390
|
* Persist a run's set. Writes nothing when the run changed nothing, so the
|
|
329
391
|
* last set on disk is always the last run that wrote.
|
|
@@ -331,6 +393,7 @@ function coalesceEntries(acc) {
|
|
|
331
393
|
* @returns {Promise<string|null>} the set id (file name), or null
|
|
332
394
|
*/
|
|
333
395
|
export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
|
|
396
|
+
await migrateLegacyJournal(root);
|
|
334
397
|
const entries = coalesceEntries(acc);
|
|
335
398
|
if (entries.length === 0) return null;
|
|
336
399
|
await ignoreJournal(root);
|
|
@@ -341,6 +404,14 @@ export async function commitJournal(root, command, acc, { at = Date.now() } = {}
|
|
|
341
404
|
const tmp = join(dir, `${id}.tmp-${process.pid}`);
|
|
342
405
|
await writeFile(tmp, body, 'utf8');
|
|
343
406
|
await rename(tmp, join(dir, id));
|
|
407
|
+
// The log is the record; the journal set is what `undo` restores from. Written after the
|
|
408
|
+
// set, so the event describes what landed rather than what was intended (Q73).
|
|
409
|
+
await noteEvent(root, {
|
|
410
|
+
kind: 'write.committed',
|
|
411
|
+
subject: command,
|
|
412
|
+
tree: null,
|
|
413
|
+
data: { entries: entries.length, paths: entries.map((entry) => entry.path) },
|
|
414
|
+
});
|
|
344
415
|
return id;
|
|
345
416
|
}
|
|
346
417
|
|
|
@@ -365,7 +436,10 @@ export async function journalHygiene(root) {
|
|
|
365
436
|
|
|
366
437
|
/** Set ids newest-first, undone sets excluded. */
|
|
367
438
|
export async function listJournalSets(root) {
|
|
368
|
-
// The pre-move directory is a fallback for one release (review 0003 Q19)
|
|
439
|
+
// The pre-move directory is a fallback for one release (review 0003 Q19);
|
|
440
|
+
// the sets in it move into the current directory on the next write
|
|
441
|
+
// (`migrateLegacyJournal`, plan 0005 H3), so the fallback only serves a
|
|
442
|
+
// repo that has not written since the move.
|
|
369
443
|
let names;
|
|
370
444
|
try {
|
|
371
445
|
names = await readdir(join(root, JOURNAL_DIR));
|
|
@@ -459,9 +533,7 @@ function entryBytes(entry) {
|
|
|
459
533
|
/** Restore one entry: bytes and permission bits for files, the value for config. */
|
|
460
534
|
async function restoreEntry(root, entry) {
|
|
461
535
|
if (entry.kind === 'git-config') {
|
|
462
|
-
await
|
|
463
|
-
cwd: root,
|
|
464
|
-
});
|
|
536
|
+
await git(root, entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before]);
|
|
465
537
|
return;
|
|
466
538
|
}
|
|
467
539
|
const abs = join(root, entry.path);
|
|
@@ -541,5 +613,7 @@ export async function undoLastJournalSet(root) {
|
|
|
541
613
|
const [id] = await listJournalSets(root);
|
|
542
614
|
if (!id) return { empty: true };
|
|
543
615
|
const result = await undoJournalSet(root, id);
|
|
616
|
+
// The set is gone from disk, so the log is now the only record it existed (Q73).
|
|
617
|
+
await noteEvent(root, { kind: 'write.undone', subject: id, data: { restored: result?.restored?.length ?? 0 } });
|
|
544
618
|
return { set: id, ...result };
|
|
545
619
|
}
|
package/src/kernel/card.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { readFile } from 'node:fs/promises';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
|
|
25
|
-
import {
|
|
25
|
+
import { cardBuilds, cardFocused, cardFallbacks, ecosystemGateRows, evalTemplate, matches } from '../ecosystem/records.mjs';
|
|
26
26
|
import { resolveNodeRunner, runScript } from '../ecosystem/node-runner.mjs';
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -53,59 +53,35 @@ const VERBS = CARD_VERBS;
|
|
|
53
53
|
const DROP_ORDER = ['format', 'typecheck', 'lint', 'build'];
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Build commands for ecosystems whose build entrypoint is not a package script.
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
56
|
+
* Build commands for ecosystems whose build entrypoint is not a package script. The rows are
|
|
57
|
+
* the records' `card.build` facet, in first-match-wins order; `npm` is handled before this
|
|
58
|
+
* table, because a Node repository states its own build script and stating it again here
|
|
59
|
+
* would be a second answer to the same question.
|
|
60
60
|
*/
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
{ fact: 'has-nuget', command: 'dotnet build' },
|
|
65
|
-
{ fact: 'has-swift-package', command: 'swift build' },
|
|
66
|
-
{ fact: 'has-mix', command: 'mix compile' },
|
|
67
|
-
{ stack: 'gradle', command: './gradlew build' },
|
|
68
|
-
];
|
|
61
|
+
function buildRows(root) {
|
|
62
|
+
return cardBuilds(root);
|
|
63
|
+
}
|
|
69
64
|
|
|
70
65
|
/**
|
|
71
|
-
* One focused-test template per path kind.
|
|
72
|
-
*
|
|
73
|
-
* `has-uv-lock` before `has-pyproject`, `has-android-manifest` before
|
|
74
|
-
* tag, `has-deno-config` before the Node fallback. A duplicate template
|
|
75
|
-
* kind would be two answers, and the second is the wrong one.
|
|
66
|
+
* One focused-test template per path kind. The rows are the records' `card.focused` facet,
|
|
67
|
+
* in first-match-wins order: a repository that carries more than one marker gets the more
|
|
68
|
+
* specific template — `has-uv-lock` before `has-pyproject`, `has-android-manifest` before
|
|
69
|
+
* the plain `gradle` tag, `has-deno-config` before the Node fallback. A duplicate template
|
|
70
|
+
* for the same path kind would be two answers, and the second is the wrong one.
|
|
71
|
+
*
|
|
72
|
+
* The Node row is the one whose shape depends on the repo's own runner: a repository whose
|
|
73
|
+
* test script is vitest or jest takes a file argument through the script; one with no such
|
|
74
|
+
* script gets the built-in runner, which is the only answer that needs nothing installed to
|
|
75
|
+
* already be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`) — that
|
|
76
|
+
* choice is the template's `dynamic` form in the record, resolved by `evalTemplate`.
|
|
76
77
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{ kind: 'Maven', when: (s, f) => f.has('has-maven-pom'), template: () => './mvnw test -Dtest={class}' },
|
|
81
|
-
{ kind: 'Go package', when: (s, f) => f.has('has-go-mod'), template: () => 'go test ./{pkg} -run {Test}' },
|
|
82
|
-
{ kind: 'Rust', when: (s, f) => f.has('has-cargo-toml'), template: () => 'cargo test {name}' },
|
|
83
|
-
{ kind: 'Python', when: (s, f) => f.has('has-uv-lock'), template: () => 'uv run -m pytest {file}::{test}' },
|
|
84
|
-
{ kind: 'Python', when: (s, f) => f.has('has-pyproject') || f.has('has-requirements'), template: () => 'pytest {file}::{test}' },
|
|
85
|
-
{ kind: '.NET', when: (s, f) => f.has('has-nuget'), template: () => 'dotnet test --filter {test}' },
|
|
86
|
-
{ kind: 'Swift', when: (s, f) => f.has('has-swift-package'), template: () => 'swift test --filter {test}' },
|
|
87
|
-
{ kind: 'Deno', when: (s, f) => f.has('has-deno-config'), template: () => 'deno test {file}' },
|
|
88
|
-
{ kind: 'Ruby', when: (s, f) => f.has('has-gemfile'), template: () => 'bundle exec rspec {file}' },
|
|
89
|
-
{ kind: 'Dart', when: (s, f) => f.has('has-pubspec'), template: () => 'dart test {file}' },
|
|
90
|
-
{ kind: 'Elixir', when: (s, f) => f.has('has-mix'), template: () => 'mix test {file}' },
|
|
91
|
-
{
|
|
92
|
-
kind: 'Node',
|
|
93
|
-
when: (s) => s.has('node'),
|
|
94
|
-
// The repo's own runner decides the shape. A repository whose test script is vitest
|
|
95
|
-
// or jest takes a file argument through the script; one with no such script gets the
|
|
96
|
-
// built-in runner, which is the only answer that needs nothing installed to already
|
|
97
|
-
// be true. `<manager> test -- <file>` is the ADR-010 shape (never `npx`).
|
|
98
|
-
template: (s, f, scripts, runner) => {
|
|
99
|
-
const test = scripts?.test ?? '';
|
|
100
|
-
if (!/vitest|jest/.test(test)) return 'node --test {file}';
|
|
101
|
-
const run = runner?.run ?? 'npm';
|
|
102
|
-
return `${run} test -- {file}`;
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
];
|
|
78
|
+
function focusedRows(root) {
|
|
79
|
+
return cardFocused(root);
|
|
80
|
+
}
|
|
106
81
|
|
|
107
82
|
/**
|
|
108
|
-
* Verbs for ecosystems the gate table does not seed, used by the card alone.
|
|
83
|
+
* Verbs for ecosystems the gate table does not seed, used by the card alone. The rows are
|
|
84
|
+
* the records' `card.fallback` facet.
|
|
109
85
|
*
|
|
110
86
|
* Python is the case, and it is not an oversight that the tool seeds no Python gate: the
|
|
111
87
|
* runner genuinely varies (uv, poetry, tox, nox, a project wrapper), so a seeded gate would
|
|
@@ -116,10 +92,9 @@ const FOCUSED = [
|
|
|
116
92
|
* test. Without a lockfile the card names the plain runner, which is what the python
|
|
117
93
|
* profile assumes when it says "no `pytest.skip`".
|
|
118
94
|
*/
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
];
|
|
95
|
+
function fallbackRows(root) {
|
|
96
|
+
return cardFallbacks(root);
|
|
97
|
+
}
|
|
123
98
|
|
|
124
99
|
/** A non-empty string, or null. Guards JSON that is present but not a command. */
|
|
125
100
|
function text(value) {
|
|
@@ -165,7 +140,7 @@ async function readScripts(root) {
|
|
|
165
140
|
* repository has declared nothing. The last is the same table the tool seeds gates from,
|
|
166
141
|
* so the card and the gate list cannot name two different commands for one repository.
|
|
167
142
|
*/
|
|
168
|
-
function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
|
|
143
|
+
function resolveVerb(verb, { manifest, facts, stack, scripts, runner, root }) {
|
|
169
144
|
const declared = text(manifest?.gates?.commands?.[verb]);
|
|
170
145
|
if (declared) return declared;
|
|
171
146
|
|
|
@@ -179,20 +154,19 @@ function resolveVerb(verb, { manifest, facts, stack, scripts, runner }) {
|
|
|
179
154
|
|
|
180
155
|
if (verb === 'build') {
|
|
181
156
|
if (text(scripts?.build)) return runScript(runner?.id ?? 'npm', 'build');
|
|
182
|
-
for (const row of
|
|
183
|
-
if (row.
|
|
184
|
-
if (row.stack && stack.has(row.stack)) return row.command;
|
|
157
|
+
for (const row of buildRows(root)) {
|
|
158
|
+
if (matches(row.condition, { facts, stack })) return row.command;
|
|
185
159
|
}
|
|
186
160
|
return null;
|
|
187
161
|
}
|
|
188
162
|
|
|
189
|
-
for (const eco of
|
|
163
|
+
for (const eco of ecosystemGateRows(root)) {
|
|
190
164
|
if (!facts.has(eco.fact)) continue;
|
|
191
165
|
const command = text(eco.commands?.[verb]);
|
|
192
166
|
if (command) return command;
|
|
193
167
|
}
|
|
194
|
-
for (const row of
|
|
195
|
-
if (!row.
|
|
168
|
+
for (const row of fallbackRows(root)) {
|
|
169
|
+
if (!matches(row.condition, { facts, stack })) continue;
|
|
196
170
|
const command = text(row.commands?.[verb]);
|
|
197
171
|
if (command) return command;
|
|
198
172
|
}
|
|
@@ -246,17 +220,17 @@ export async function commandCard({ root, manifest = null, stack = [], facts = [
|
|
|
246
220
|
|
|
247
221
|
const lines = [];
|
|
248
222
|
for (const verb of VERBS) {
|
|
249
|
-
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective });
|
|
223
|
+
const command = resolveVerb(verb, { manifest, facts: factSet, stack: stackSet, scripts, runner: effective, root });
|
|
250
224
|
if (command) lines.push({ label: verb, command, verb });
|
|
251
225
|
}
|
|
252
|
-
for (const entry of
|
|
253
|
-
if (!entry.
|
|
226
|
+
for (const entry of focusedRows(root)) {
|
|
227
|
+
if (!matches(entry.condition, { facts: factSet, stack: stackSet })) continue;
|
|
254
228
|
// One template per path kind: `has-uv-lock` and `has-pyproject` both match a uv repo,
|
|
255
229
|
// and two Python lines would be two answers with the second one wrong.
|
|
256
230
|
if (lines.some((l) => l.kind === entry.kind)) continue;
|
|
257
231
|
lines.push({
|
|
258
232
|
label: `focused test (${entry.kind})`,
|
|
259
|
-
command: entry.template
|
|
233
|
+
command: evalTemplate(entry.template, { scripts, runner: effective }),
|
|
260
234
|
kind: entry.kind,
|
|
261
235
|
verb: 'focused',
|
|
262
236
|
});
|
package/src/kernel/checks.mjs
CHANGED
|
@@ -9,14 +9,11 @@
|
|
|
9
9
|
// index has the edges (R21). A direction still has to be named before the prose
|
|
10
10
|
// can leave, or the line would say Enforced and nothing would be enforcing it.
|
|
11
11
|
|
|
12
|
-
import { execFile } from 'node:child_process';
|
|
13
12
|
import { readFile, stat } from 'node:fs/promises';
|
|
14
13
|
import { relative, resolve, sep } from 'node:path';
|
|
15
|
-
import { promisify } from 'node:util';
|
|
16
14
|
|
|
17
15
|
import { CLI } from '../name.mjs';
|
|
18
|
-
|
|
19
|
-
const exec = promisify(execFile);
|
|
16
|
+
import { git, gitPaths } from '../sys/git.mjs';
|
|
20
17
|
|
|
21
18
|
export const CHECK_MARKER = 'simt-check';
|
|
22
19
|
|
|
@@ -360,8 +357,7 @@ function exempt(rel, prefixes) {
|
|
|
360
357
|
|
|
361
358
|
async function tracked(root) {
|
|
362
359
|
try {
|
|
363
|
-
|
|
364
|
-
return stdout.split('\0').filter((p) => p !== '');
|
|
360
|
+
return await gitPaths(root, ['ls-files']);
|
|
365
361
|
} catch {
|
|
366
362
|
return null;
|
|
367
363
|
}
|
|
@@ -370,8 +366,7 @@ async function tracked(root) {
|
|
|
370
366
|
async function workDiff(root) {
|
|
371
367
|
let text = '';
|
|
372
368
|
try {
|
|
373
|
-
|
|
374
|
-
text = stdout;
|
|
369
|
+
text = await git(root, ['diff', '--no-ext-diff', '--unified=0', 'HEAD'], { maxBuffer: 8 * 1024 * 1024 });
|
|
375
370
|
} catch (error) {
|
|
376
371
|
if (error.code === 'ENOENT') return null;
|
|
377
372
|
text = typeof error.stdout === 'string' ? error.stdout : '';
|
|
@@ -379,8 +374,7 @@ async function workDiff(root) {
|
|
|
379
374
|
const files = parseDiff(text);
|
|
380
375
|
let untracked = [];
|
|
381
376
|
try {
|
|
382
|
-
|
|
383
|
-
untracked = stdout.split('\0').filter((p) => p !== '');
|
|
377
|
+
untracked = await gitPaths(root, ['ls-files', '--others', '--exclude-standard']);
|
|
384
378
|
} catch {
|
|
385
379
|
return { files };
|
|
386
380
|
}
|
package/src/lease.mjs
CHANGED
|
@@ -25,7 +25,7 @@ export const LEASE_FILE = localRel('leases.json');
|
|
|
25
25
|
const LEASE_FILE_LEGACY = '.simt/leases.json';
|
|
26
26
|
|
|
27
27
|
/** The schema the file is written with; a reader that does not know it refuses. */
|
|
28
|
-
const LEASE_SCHEMA =
|
|
28
|
+
const LEASE_SCHEMA = 2;
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* How long a claim lives when the caller does not say.
|
|
@@ -35,6 +35,89 @@ const LEASE_SCHEMA = 1;
|
|
|
35
35
|
*/
|
|
36
36
|
export const DEFAULT_TTL_SECONDS = 3600;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A v1 record in the v2 shape.
|
|
40
|
+
*
|
|
41
|
+
* v1 stored `{path, session, harness, expiresAt}`; v2 stores a resource, a holder, a
|
|
42
|
+
* fencing token and an acquisition time (Q74). The reader accepts both because a claim
|
|
43
|
+
* file written by the previous release is still a claim the hook must honour, and
|
|
44
|
+
* refusing it would silently un-lease every held path on upgrade.
|
|
45
|
+
*/
|
|
46
|
+
export function toV2(entry) {
|
|
47
|
+
if (entry !== null && typeof entry === 'object' && entry.resource !== undefined && entry.holder !== undefined) {
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
// `...entry` keeps the fields a caller put on its own claim - a slice claim carries
|
|
51
|
+
// `kind: 'slice'` and the slice id - which the schema does not name but must not drop.
|
|
52
|
+
return {
|
|
53
|
+
...entry,
|
|
54
|
+
resource: { kind: 'path', id: entry.path },
|
|
55
|
+
holder: { session: entry.session, harness: entry.harness ?? null },
|
|
56
|
+
fence: Number.isInteger(entry.fence) ? entry.fence : 1,
|
|
57
|
+
acquiredAt: typeof entry.acquiredAt === 'number' ? entry.acquiredAt : null,
|
|
58
|
+
expiresAt: entry.expiresAt,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The v2 record flattened into the fields every reader of this tool already uses.
|
|
64
|
+
*
|
|
65
|
+
* Keeping `path`, `session`, `harness` and `expiresAt` on the in-memory entry is not
|
|
66
|
+
* backwards compatibility theatre: the hook, the command and four call sites compare
|
|
67
|
+
* those fields, and rewriting all of them to reach through `resource.id` would be a
|
|
68
|
+
* second change riding on a schema change.
|
|
69
|
+
*/
|
|
70
|
+
export function flattenLease(entry) {
|
|
71
|
+
const v2 = toV2(entry);
|
|
72
|
+
return {
|
|
73
|
+
...v2,
|
|
74
|
+
path: v2.resource.id,
|
|
75
|
+
session: v2.holder.session,
|
|
76
|
+
harness: v2.holder.harness ?? null,
|
|
77
|
+
expiresAt: v2.expiresAt,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A record that is worth honouring, or null. A bad expiry would hold a path forever. */
|
|
82
|
+
function normaliseLease(entry) {
|
|
83
|
+
if (entry === null || typeof entry !== 'object') return null;
|
|
84
|
+
const v2 = entry.resource !== undefined ? entry : toV2(entry);
|
|
85
|
+
const id = v2?.resource?.id;
|
|
86
|
+
const session = v2?.holder?.session;
|
|
87
|
+
if (typeof id !== 'string' || typeof session !== 'string' || !Number.isFinite(v2.expiresAt)) return null;
|
|
88
|
+
if (v2.resource.kind !== 'path' && v2.resource.kind !== 'dir') return null;
|
|
89
|
+
return flattenLease(v2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether a claim covers a repository-relative path.
|
|
94
|
+
*
|
|
95
|
+
* A `dir` claim covers everything under it, which is what makes "this session owns
|
|
96
|
+
* `src/index/`" one claim instead of forty (Q74). The prefix test includes the separator
|
|
97
|
+
* so `src/index` does not cover `src/indexer`.
|
|
98
|
+
*/
|
|
99
|
+
export function leaseCovers(lease, rel) {
|
|
100
|
+
const resource = toV2(lease).resource;
|
|
101
|
+
if (resource.kind === 'dir') return rel === resource.id || rel.startsWith(`${resource.id}/`);
|
|
102
|
+
return rel === resource.id;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The store the lease commands read and write through.
|
|
107
|
+
*
|
|
108
|
+
* A file today; a remote store and a server clock later (Q74). Everything above this line
|
|
109
|
+
* is a function of the records, not of the file, so the second implementation changes
|
|
110
|
+
* where `read` and `write` point and nothing else.
|
|
111
|
+
*/
|
|
112
|
+
export function fileLeaseStore(root) {
|
|
113
|
+
return {
|
|
114
|
+
root,
|
|
115
|
+
read: () => readLeases(root),
|
|
116
|
+
write: (leases) => writeLeases(root, leases),
|
|
117
|
+
withLock: (fn) => withLeaseLock(root, fn),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
38
121
|
/**
|
|
39
122
|
* The one spelling a path has for lease matching: repository-relative, symlinks
|
|
40
123
|
* resolved, case-folded when the repository's git compares case-insensitively, or
|
|
@@ -105,10 +188,7 @@ export async function readLeases(root) {
|
|
|
105
188
|
}
|
|
106
189
|
// A malformed entry is dropped rather than trusted: an expiry that is not a number
|
|
107
190
|
// would otherwise live forever, which is the opposite of what a TTL promises.
|
|
108
|
-
const leases = parsed.leases.filter(
|
|
109
|
-
(l) => l !== null && typeof l === 'object' && typeof l.path === 'string' &&
|
|
110
|
-
typeof l.session === 'string' && Number.isFinite(l.expiresAt),
|
|
111
|
-
);
|
|
191
|
+
const leases = parsed.leases.map(normaliseLease).filter((l) => l !== null);
|
|
112
192
|
return { path, leases, error: null };
|
|
113
193
|
} catch (error) {
|
|
114
194
|
return { path, leases: [], error: `${LEASE_FILE} is not valid JSON (${error.message})` };
|
|
@@ -136,8 +216,23 @@ export function activeLeases({ leases } = { leases: [] }, now = Date.now()) {
|
|
|
136
216
|
*/
|
|
137
217
|
export async function writeLeases(root, leases) {
|
|
138
218
|
const path = join(root, LEASE_FILE);
|
|
139
|
-
|
|
140
|
-
|
|
219
|
+
// v2 on disk: a resource, a holder and a fence, sorted by resource id so two writes of
|
|
220
|
+
// the same claims produce the same bytes (Q74).
|
|
221
|
+
const records = leases
|
|
222
|
+
.map(toV2)
|
|
223
|
+
.map((l) => ({
|
|
224
|
+
// Additive, not a rename: the pre-edit hook, the slice claim and the tests read
|
|
225
|
+
// `path` and `session` straight off the file, and a v2 writer that stopped writing
|
|
226
|
+
// them would break every one of those readers at once.
|
|
227
|
+
...l,
|
|
228
|
+
resource: l.resource,
|
|
229
|
+
holder: { session: l.holder.session, harness: l.holder.harness ?? null },
|
|
230
|
+
fence: l.fence,
|
|
231
|
+
acquiredAt: l.acquiredAt,
|
|
232
|
+
expiresAt: l.expiresAt,
|
|
233
|
+
}))
|
|
234
|
+
.sort((a, b) => a.resource.id.localeCompare(b.resource.id));
|
|
235
|
+
const body = `${JSON.stringify({ schemaVersion: LEASE_SCHEMA, leases: records }, null, 2)}\n`;
|
|
141
236
|
await mkdir(dirname(path), { recursive: true });
|
|
142
237
|
// Atomic against a concurrent writer and a concurrent reader: a half-written
|
|
143
238
|
// claim file fails closed for every edit (review 0003 Q20).
|