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/commands/upgrade.mjs
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
// 4. A repo it could not upgrade is reported loudly and fails the run. A repo left
|
|
22
22
|
// silently behind is the failure `compare` exists to surface, and this command must
|
|
23
23
|
// not be the thing that creates it.
|
|
24
|
+
// 5. With no root named, it upgrades THIS repository only. The workspace is a claim
|
|
25
|
+
// about scope, so it needs `--all` or an explicit root (review 0003 Q33): the old
|
|
26
|
+
// default resolved the workspace and a dry run inside one repo planned all sixteen.
|
|
24
27
|
|
|
25
28
|
import { mkdir } from 'node:fs/promises';
|
|
26
29
|
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
@@ -55,12 +58,41 @@ export async function upgradeCommand(positional, flags) {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
|
|
61
|
+
// `--root`, a positional, and `--workspace` are all explicit claims about scope.
|
|
62
|
+
// Anything else is the enclosing repository, below.
|
|
63
|
+
const target = explicit || flags.workspace
|
|
64
|
+
? await targetRoot(flags, positional)
|
|
65
|
+
: await (
|
|
66
|
+
// No root named: the scope is THIS repository, not the workspace. The old
|
|
67
|
+
// default resolved the workspace and swept every sibling - 16 repos planned
|
|
68
|
+
// from a dry run inside one of them. A workspace is a claim about scope, so
|
|
69
|
+
// it now needs `--all` or an explicit root, the way `--all` always did.
|
|
70
|
+
//
|
|
71
|
+
// The note fires only when the old default would have planned a different
|
|
72
|
+
// directory: a configured workspace that is not this repo. No workspace, or
|
|
73
|
+
// one equal to the cwd, changes nothing - and a note about an unchanged
|
|
74
|
+
// behaviour is noise.
|
|
75
|
+
(async () => {
|
|
76
|
+
const ws = await resolveWorkspace(flags);
|
|
77
|
+
if (ws.root && resolve(ws.root) !== process.cwd()) {
|
|
78
|
+
note(`note the default scope is this repository; the workspace at ${ws.root} is not upgraded. Pass --root ${ws.root} (or --all) to upgrade it`);
|
|
79
|
+
}
|
|
80
|
+
return { root: process.cwd(), source: 'default', detail: 'the enclosing repository', ws };
|
|
81
|
+
})()
|
|
82
|
+
);
|
|
59
83
|
const root = resolve(target.root);
|
|
60
84
|
const found = await discover(root);
|
|
61
85
|
if (found.error) {
|
|
62
86
|
return fail(`${root}: ${found.error}`, EXIT.CONFIG, 'pass --root <path>');
|
|
63
87
|
}
|
|
88
|
+
if (found.repos.length === 0 && found.worktrees.length === 1) {
|
|
89
|
+
// A worktree passed directly is not counted as a repo by discover (its files are
|
|
90
|
+
// its parent's HEAD checked out elsewhere, so drift reporting would double-count
|
|
91
|
+
// it). Upgrading is different: the worktree carries the same rule content as its
|
|
92
|
+
// parent, so it is a valid single target. This is the shape the pre-push gate
|
|
93
|
+
// materializes - a detached worktree at HEAD that the suite runs inside.
|
|
94
|
+
found.repos = [found.worktrees[0].path];
|
|
95
|
+
}
|
|
64
96
|
if (found.repos.length === 0) {
|
|
65
97
|
return fail(
|
|
66
98
|
`${root} contains no git repositories`,
|
|
@@ -184,8 +216,12 @@ export async function upgradeCommand(positional, flags) {
|
|
|
184
216
|
}
|
|
185
217
|
}
|
|
186
218
|
|
|
219
|
+
// One verdict, stated once. `ok` used to be `skipped === 0`, so a workspace with a
|
|
220
|
+
// repo that never adopted answered ok:false and exited 0: the document and the process
|
|
221
|
+
// disagreed, and a caller had no way to tell which one was the answer (review 0003 Q65).
|
|
222
|
+
const code = exitFor(plans, failed);
|
|
187
223
|
const payload = {
|
|
188
|
-
ok:
|
|
224
|
+
ok: code === EXIT.OK,
|
|
189
225
|
command: 'upgrade',
|
|
190
226
|
root,
|
|
191
227
|
shape: found.shape,
|
|
@@ -200,7 +236,7 @@ export async function upgradeCommand(positional, flags) {
|
|
|
200
236
|
|
|
201
237
|
if (isJson(flags)) {
|
|
202
238
|
emitJson(payload);
|
|
203
|
-
return
|
|
239
|
+
return code;
|
|
204
240
|
}
|
|
205
241
|
|
|
206
242
|
emit(render(plans, summary, { root, shape: found.shape, source: target.source, sha, applied, failed }));
|
|
@@ -224,7 +260,7 @@ export async function upgradeCommand(positional, flags) {
|
|
|
224
260
|
} else {
|
|
225
261
|
note('note every consumer already runs this simt-dev');
|
|
226
262
|
}
|
|
227
|
-
return
|
|
263
|
+
return code;
|
|
228
264
|
}
|
|
229
265
|
|
|
230
266
|
/**
|
package/src/commands/why.mjs
CHANGED
|
@@ -11,8 +11,10 @@ import { join } from 'node:path';
|
|
|
11
11
|
import { promisify } from 'node:util';
|
|
12
12
|
|
|
13
13
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
14
|
-
import { CLI
|
|
14
|
+
import { CLI } from '../name.mjs';
|
|
15
15
|
import { FORMATTABLE, GATES } from '../hooks/gates.mjs';
|
|
16
|
+
import { loadManifest } from '../manifest/schema.mjs';
|
|
17
|
+
import { decisionsDir } from '../decisions/dir.mjs';
|
|
16
18
|
import {
|
|
17
19
|
decisionHits,
|
|
18
20
|
scopeHits,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
} from '../index/govern.mjs';
|
|
24
26
|
import { splitFrontmatter } from '../memory/decide.mjs';
|
|
25
27
|
import { listPromotedLessons } from '../memory/lessons.mjs';
|
|
28
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
26
29
|
|
|
27
30
|
const exec = promisify(execFile);
|
|
28
31
|
|
|
@@ -30,7 +33,7 @@ const RULE_DIRS = ['rules', 'rules/stacks', 'rules/mdc', '.simt/profiles'];
|
|
|
30
33
|
|
|
31
34
|
export async function whyCommand(args, flags, commands = []) {
|
|
32
35
|
if (args.length > 1) return fail(`unknown why argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} why [path]`);
|
|
33
|
-
const root = flags
|
|
36
|
+
const root = commandRoot(flags);
|
|
34
37
|
const listed = await tracked(root);
|
|
35
38
|
if (listed.error) return listed.error;
|
|
36
39
|
const files = new Set(listed.files);
|
|
@@ -44,22 +47,22 @@ export async function whyCommand(args, flags, commands = []) {
|
|
|
44
47
|
const items = sortHits([...hits, ...scopeHits(target, corpus)]);
|
|
45
48
|
const payload = { ok: true, command: 'why', root, path: target, items };
|
|
46
49
|
if (isJson(flags)) {
|
|
47
|
-
emitJson(payload
|
|
50
|
+
emitJson(payload);
|
|
48
51
|
return EXIT.OK;
|
|
49
52
|
}
|
|
50
53
|
const lines = items.map((item) => `${item.kind.padEnd(10)}${item.id} ${item.detail}`);
|
|
51
|
-
emit(lines.length > 0 ? lines.join('\n') : 'none'
|
|
54
|
+
emit(lines.length > 0 ? lines.join('\n') : 'none');
|
|
52
55
|
return EXIT.OK;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
function reportUnresolved(root, flags, unresolved) {
|
|
56
59
|
const payload = { ok: true, command: 'why', root, unresolved };
|
|
57
60
|
if (isJson(flags)) {
|
|
58
|
-
emitJson(payload
|
|
61
|
+
emitJson(payload);
|
|
59
62
|
return EXIT.OK;
|
|
60
63
|
}
|
|
61
64
|
const lines = unresolved.map((item) => `UNRESOLVED-AFFECTS ${item.token} ${item.id}`);
|
|
62
|
-
emit([`unresolved ${unresolved.length}`, ...lines].join('\n')
|
|
65
|
+
emit([`unresolved ${unresolved.length}`, ...lines].join('\n'));
|
|
63
66
|
return EXIT.OK;
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -67,8 +70,10 @@ function reportUnresolved(root, flags, unresolved) {
|
|
|
67
70
|
// the critic's brief resolves governance the same way without a second copy.
|
|
68
71
|
export async function tracked(root) {
|
|
69
72
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
// `-z`: without it git C-quotes a non-ASCII path and the reader's real
|
|
74
|
+
// filename never matches it (review 0003 Q39).
|
|
75
|
+
const { stdout } = await exec('git', ['ls-files', '-z'], { cwd: root, maxBuffer: 16 << 20 });
|
|
76
|
+
return { files: stdout.split('\0').filter(Boolean) };
|
|
72
77
|
} catch (error) {
|
|
73
78
|
return {
|
|
74
79
|
error: fail(
|
|
@@ -81,10 +86,13 @@ export async function tracked(root) {
|
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
export async function decisionRecords(root) {
|
|
84
|
-
|
|
89
|
+
// Q55: the repository's own records, wherever it keeps them — not a hard-coded path.
|
|
90
|
+
const dir = join(root, await decisionsDir(root));
|
|
85
91
|
let names = [];
|
|
86
92
|
try {
|
|
87
|
-
names = (await readdir(dir))
|
|
93
|
+
names = (await readdir(dir))
|
|
94
|
+
.filter((name) => /^ADR-\d{3,}-.*\.md$/i.test(name))
|
|
95
|
+
.sort((a, b) => Number(/^ADR-(\d{3,})/i.exec(a)[1]) - Number(/^ADR-(\d{3,})/i.exec(b)[1]));
|
|
88
96
|
} catch {
|
|
89
97
|
return [];
|
|
90
98
|
}
|
|
@@ -166,8 +174,7 @@ export async function lessonScopes(root) {
|
|
|
166
174
|
|
|
167
175
|
async function gateCommands(root) {
|
|
168
176
|
try {
|
|
169
|
-
const
|
|
170
|
-
const commands = parsed?.gates?.commands;
|
|
177
|
+
const commands = (await loadManifest(root)).manifest?.gates?.commands;
|
|
171
178
|
return commands && typeof commands === 'object' ? commands : {};
|
|
172
179
|
} catch {
|
|
173
180
|
return {};
|
|
@@ -14,6 +14,25 @@ import { promisify } from 'node:util';
|
|
|
14
14
|
import { CHAIN_MARKER, EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, SHIM_MARKER, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
|
|
15
15
|
import { foreignHooks, resolveHooksDir } from '../hooks/where.mjs';
|
|
16
16
|
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
17
|
+
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
18
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Node manager id for this repository, or null without a package.json.
|
|
22
|
+
* Lockfile first, `packageManager` next — the one resolver (review 0003 Q31).
|
|
23
|
+
*/
|
|
24
|
+
async function nodeManagerFor(root) {
|
|
25
|
+
if (!(await hasNpm(root))) return null;
|
|
26
|
+
const detected = await fingerprint(root);
|
|
27
|
+
let packageManager = null;
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
30
|
+
if (typeof pkg.packageManager === 'string') packageManager = pkg.packageManager;
|
|
31
|
+
} catch {
|
|
32
|
+
// Unreadable here leaves the npm default; the manifest path reports it.
|
|
33
|
+
}
|
|
34
|
+
return resolveNodeRunner({ facts: detected.facts, packageManager }).id;
|
|
35
|
+
}
|
|
17
36
|
|
|
18
37
|
const exec = promisify(execFile);
|
|
19
38
|
|
|
@@ -149,7 +168,10 @@ async function describeShim(path, root) {
|
|
|
149
168
|
const chain = await readChainedShim(path);
|
|
150
169
|
stale = chain === null || content !== renderChainedShim(chain.event, chain.target);
|
|
151
170
|
} else {
|
|
152
|
-
|
|
171
|
+
// Q31: the comparison renders with the repo's own manager, or a pnpm repo
|
|
172
|
+
// reads as stale the moment this tool ships — the same one-answer rule as
|
|
173
|
+
// the install path above.
|
|
174
|
+
stale = content !== renderShim(basename(path), { npm: await hasNpm(root), manager: await nodeManagerFor(root), version: packageVersion() });
|
|
153
175
|
}
|
|
154
176
|
} catch {
|
|
155
177
|
stale = true;
|
package/src/compare/learn.mjs
CHANGED
|
@@ -13,17 +13,24 @@
|
|
|
13
13
|
// every ADR draft are byte-identical (no wall-clock timestamps, fixed sorting and
|
|
14
14
|
// rounding).
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
17
|
import { basename, join } from 'node:path';
|
|
18
|
+
import { mapCapped, DEFAULT_CONCURRENCY } from './parallel.mjs';
|
|
18
19
|
|
|
19
20
|
import { policiesFor } from '../agenthooks/policy.mjs';
|
|
21
|
+
import { DEL_R_TOLERANCE, parseAblationArmName } from '../bench/ablate.mjs';
|
|
20
22
|
import { compareArms, evaluateRunRecord, loadPrices, loadRunRecords } from '../bench/report.mjs';
|
|
21
23
|
import { GATES } from '../hooks/gates.mjs';
|
|
22
24
|
import { readLedger } from '../ledger/index.mjs';
|
|
23
25
|
import { readLessonCandidates } from '../memory/lessons.mjs';
|
|
26
|
+
import { STATE_DIR } from '../name.mjs';
|
|
24
27
|
import { readManifest } from '../projection/manifest.mjs';
|
|
28
|
+
import { localRel, readStateFile } from '../state.mjs';
|
|
25
29
|
|
|
26
|
-
const
|
|
30
|
+
const ABLATION_REL = localRel('bench', 'ablation.json');
|
|
31
|
+
// Where pre-Q19 checkouts keep the file: the writer's path before the move to
|
|
32
|
+
// machine-local state (review 0003 Q50).
|
|
33
|
+
const ABLATION_REL_LEGACY = join(STATE_DIR, 'bench', 'ablation.json');
|
|
27
34
|
|
|
28
35
|
const POLICY_BY_ID = new Map(policiesFor('pre-tool-use').map((p) => [p.id, p]));
|
|
29
36
|
const POLICY_BY_ADR = new Map(policiesFor('pre-tool-use').map((p) => [p.adr, p]));
|
|
@@ -78,7 +85,7 @@ export function classifyGateCommand(cmd, manifest = null) {
|
|
|
78
85
|
for (const gate of GATE_ENTRIES) {
|
|
79
86
|
if (gate.kind === 'script' && gate.script) {
|
|
80
87
|
const scriptRe = new RegExp(`(?:^|\\s)npm\\s+run(?:\\s+--silent)?\\s+${gate.script}(?:\\s|$)`);
|
|
81
|
-
if (scriptRe.test(raw) || (gate.script === 'test' && /\b(?:npm\s+test|node\s+--test|pytest|go\s+test)\b/.test(raw))) {
|
|
88
|
+
if (scriptRe.test(raw) || (gate.script === 'test' && /\b(?:npm\s+test|node\s+--test|pytest|go\s+test|cargo\s+test)\b/.test(raw))) {
|
|
82
89
|
return { gate: gate.name, adr: gate.adr };
|
|
83
90
|
}
|
|
84
91
|
}
|
|
@@ -162,21 +169,17 @@ export function classifyRuleViolations(candidate) {
|
|
|
162
169
|
return [...found.values()];
|
|
163
170
|
}
|
|
164
171
|
|
|
165
|
-
function
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (raw.startsWith('no-process-')) {
|
|
176
|
-
const id = raw.slice('no-process-'.length);
|
|
177
|
-
return { sectionId: `process:${id}`, arm: raw, kind: 'process', targetId: id, affects: [`rules/mdc/${id}.mdc`] };
|
|
172
|
+
async function readExplicitNoValueSections(repoPath) {
|
|
173
|
+
const { text } = await readStateFile(repoPath, ABLATION_REL, [ABLATION_REL_LEGACY]).catch(() => ({
|
|
174
|
+
text: null,
|
|
175
|
+
}));
|
|
176
|
+
if (text === null) return [];
|
|
177
|
+
try {
|
|
178
|
+
const raw = JSON.parse(text);
|
|
179
|
+
return Array.isArray(raw?.sections) ? raw.sections : Array.isArray(raw) ? raw : [];
|
|
180
|
+
} catch {
|
|
181
|
+
return [];
|
|
178
182
|
}
|
|
179
|
-
return null;
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
async function collectRepoAblationRecords(repoPath) {
|
|
@@ -188,17 +191,6 @@ async function collectRepoAblationRecords(repoPath) {
|
|
|
188
191
|
return records.map((rec) => evaluateRunRecord(rec, prices));
|
|
189
192
|
}
|
|
190
193
|
|
|
191
|
-
async function readExplicitNoValueSections(repoPath) {
|
|
192
|
-
const file = join(repoPath, '.simt', 'ablation.json');
|
|
193
|
-
try {
|
|
194
|
-
const raw = JSON.parse(await readFile(file, 'utf8'));
|
|
195
|
-
const list = Array.isArray(raw?.sections) ? raw.sections : Array.isArray(raw) ? raw : [];
|
|
196
|
-
return list;
|
|
197
|
-
} catch {
|
|
198
|
-
return [];
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
194
|
function formatAffectsList(affects) {
|
|
203
195
|
const sorted = [...new Set(affects.map(String).filter(Boolean))].sort();
|
|
204
196
|
return sorted.length > 0 ? `[${sorted.map((a) => JSON.stringify(a)).join(', ')}]` : '[]';
|
|
@@ -270,9 +262,55 @@ export function renderAdrDraft({
|
|
|
270
262
|
* adrDrafts: Array<{id: string, category: string, target: string, title: string, affects: string[], supersedes: Array<{adr: string, ruling: number|null}>, markdown: string}>
|
|
271
263
|
* }>}
|
|
272
264
|
*/
|
|
265
|
+
/**
|
|
266
|
+
* Digest-keyed memo of estate learning (review 0003 Q38).
|
|
267
|
+
*
|
|
268
|
+
* The bootstrap comparisons below are pure over the records on disk, so a
|
|
269
|
+
* second `compare` with unchanged inputs recomputes nothing. Keyed by digest,
|
|
270
|
+
* not by time, so a changed record can never read a stale answer. In-process
|
|
271
|
+
* only: the MCP server is long-lived and repeats the call per session, while a
|
|
272
|
+
* CLI run pays one computation anyway. Bounded, because estates differ.
|
|
273
|
+
*/
|
|
274
|
+
const learningCache = new Map();
|
|
275
|
+
const LEARNING_CACHE_MAX = 8;
|
|
276
|
+
|
|
277
|
+
export function clearLearningCache() {
|
|
278
|
+
learningCache.clear();
|
|
279
|
+
}
|
|
280
|
+
|
|
273
281
|
export async function learnAcrossEstate(repos) {
|
|
274
282
|
const sortedRepos = [...(repos ?? [])].sort();
|
|
275
283
|
|
|
284
|
+
// Collection first, capped and parallel: one repo's five reads overlap the
|
|
285
|
+
// next repo's, and the aggregation below stays sequential over sorted input
|
|
286
|
+
// so the output cannot depend on completion order.
|
|
287
|
+
const collected = await mapCapped(
|
|
288
|
+
sortedRepos,
|
|
289
|
+
async (repoPath) => {
|
|
290
|
+
const [manifestRes, ledgerRes, lessonsRes, benchEvaluated, explicitAblation] = await Promise.all([
|
|
291
|
+
readManifest(repoPath).catch(() => ({ manifest: null })),
|
|
292
|
+
readLedger(repoPath).catch(() => ({ entries: [] })),
|
|
293
|
+
readLessonCandidates(repoPath).catch(() => ({ candidates: [] })),
|
|
294
|
+
collectRepoAblationRecords(repoPath),
|
|
295
|
+
readExplicitNoValueSections(repoPath),
|
|
296
|
+
]);
|
|
297
|
+
return {
|
|
298
|
+
repoPath,
|
|
299
|
+
repoName: basename(repoPath),
|
|
300
|
+
manifest: manifestRes?.manifest ?? null,
|
|
301
|
+
ledgerEntries: ledgerRes?.entries ?? [],
|
|
302
|
+
candidates: lessonsRes?.candidates ?? [],
|
|
303
|
+
benchEvaluated,
|
|
304
|
+
explicitAblation,
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
{ concurrency: DEFAULT_CONCURRENCY },
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const digest = createHash('sha1').update(JSON.stringify(collected)).digest('hex');
|
|
311
|
+
const cached = learningCache.get(digest);
|
|
312
|
+
if (cached) return cached;
|
|
313
|
+
|
|
276
314
|
const ruleViolations = new Map();
|
|
277
315
|
const gateStats = new Map();
|
|
278
316
|
const ablationByArm = new Map();
|
|
@@ -284,19 +322,7 @@ export async function learnAcrossEstate(repos) {
|
|
|
284
322
|
let totalLedgerRuns = 0;
|
|
285
323
|
let totalHookDenials = 0;
|
|
286
324
|
|
|
287
|
-
for (const
|
|
288
|
-
const repoName = basename(repoPath);
|
|
289
|
-
const [manifestRes, ledgerRes, lessonsRes, benchEvaluated, explicitAblation] = await Promise.all([
|
|
290
|
-
readManifest(repoPath).catch(() => ({ manifest: null })),
|
|
291
|
-
readLedger(repoPath).catch(() => ({ entries: [] })),
|
|
292
|
-
readLessonCandidates(repoPath).catch(() => ({ candidates: [] })),
|
|
293
|
-
collectRepoAblationRecords(repoPath),
|
|
294
|
-
readExplicitNoValueSections(repoPath),
|
|
295
|
-
]);
|
|
296
|
-
|
|
297
|
-
const manifest = manifestRes?.manifest ?? null;
|
|
298
|
-
const ledgerEntries = ledgerRes?.entries ?? [];
|
|
299
|
-
const candidates = lessonsRes?.candidates ?? [];
|
|
325
|
+
for (const { repoName, manifest, ledgerEntries, candidates, benchEvaluated, explicitAblation } of collected) {
|
|
300
326
|
|
|
301
327
|
if (ledgerEntries.length > 0) reposWithLedgers += 1;
|
|
302
328
|
totalLedgerRuns += ledgerEntries.length;
|
|
@@ -357,7 +383,7 @@ export async function learnAcrossEstate(repos) {
|
|
|
357
383
|
const tagged = { ...row, task: `${repoName}:${row.task}` };
|
|
358
384
|
if (row.arm === 'current') {
|
|
359
385
|
currentRunsAll.push(tagged);
|
|
360
|
-
} else if (
|
|
386
|
+
} else if (parseAblationArmName(row.arm)) {
|
|
361
387
|
const list = ablationByArm.get(row.arm) ?? [];
|
|
362
388
|
list.push({ row: tagged, repoName });
|
|
363
389
|
ablationByArm.set(row.arm, list);
|
|
@@ -415,7 +441,7 @@ export async function learnAcrossEstate(repos) {
|
|
|
415
441
|
const noValueMap = new Map();
|
|
416
442
|
|
|
417
443
|
for (const [arm, entries] of [...ablationByArm.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
418
|
-
const parsed =
|
|
444
|
+
const parsed = parseAblationArmName(arm);
|
|
419
445
|
if (!parsed || currentRunsAll.length === 0) continue;
|
|
420
446
|
const ablatedRuns = entries.map((e) => e.row);
|
|
421
447
|
const cmp = compareArms(arm, ablatedRuns, 'current', currentRunsAll);
|
|
@@ -423,11 +449,11 @@ export async function learnAcrossEstate(repos) {
|
|
|
423
449
|
const dR = round4(cmp.deltas.R);
|
|
424
450
|
const dC = round4(cmp.deltas.C);
|
|
425
451
|
if (dR <= DEL_R_TOLERANCE && dC > 0) {
|
|
426
|
-
noValueMap.set(parsed.sectionId, {
|
|
427
|
-
sectionId: parsed.sectionId,
|
|
428
|
-
kind: parsed.kind,
|
|
429
|
-
targetId: parsed.targetId,
|
|
430
|
-
affects: parsed.
|
|
452
|
+
noValueMap.set(parsed.ablate.sectionId, {
|
|
453
|
+
sectionId: parsed.ablate.sectionId,
|
|
454
|
+
kind: parsed.ablate.kind,
|
|
455
|
+
targetId: parsed.ablate.targetId,
|
|
456
|
+
affects: [parsed.ablate.sectionId],
|
|
431
457
|
dR,
|
|
432
458
|
dC,
|
|
433
459
|
tasks: cmp.pairedTasks,
|
|
@@ -444,12 +470,6 @@ export async function learnAcrossEstate(repos) {
|
|
|
444
470
|
const isCandidate = sec.deletionCandidate === true || (sec.measured !== false && dR <= DEL_R_TOLERANCE && dC > 0);
|
|
445
471
|
if (!isCandidate) continue;
|
|
446
472
|
const [kind = 'core', targetId = sectionId] = sectionId.split(':');
|
|
447
|
-
const affects =
|
|
448
|
-
kind === 'profile'
|
|
449
|
-
? [`rules/stacks/${targetId}.md`]
|
|
450
|
-
: kind === 'process'
|
|
451
|
-
? [`rules/mdc/${targetId}.mdc`]
|
|
452
|
-
: ['rules/core.md'];
|
|
453
473
|
const existing = noValueMap.get(sectionId);
|
|
454
474
|
if (existing) {
|
|
455
475
|
existing.repos = [...new Set([...existing.repos, repoName])].sort();
|
|
@@ -458,7 +478,7 @@ export async function learnAcrossEstate(repos) {
|
|
|
458
478
|
sectionId,
|
|
459
479
|
kind,
|
|
460
480
|
targetId,
|
|
461
|
-
affects,
|
|
481
|
+
affects: [sectionId],
|
|
462
482
|
dR,
|
|
463
483
|
dC,
|
|
464
484
|
tasks: Number(sec.tasks ?? 1),
|
|
@@ -584,7 +604,7 @@ export async function learnAcrossEstate(repos) {
|
|
|
584
604
|
|
|
585
605
|
for (const s of noValueRules) {
|
|
586
606
|
const rawEntry = noValueMap.get(s.sectionId);
|
|
587
|
-
const affects = rawEntry?.affects ?? [
|
|
607
|
+
const affects = rawEntry?.affects ?? [s.sectionId];
|
|
588
608
|
const draftId = nextDraftId();
|
|
589
609
|
const title = `Prune or demote zero-value section "${s.sectionId}" (dR=${s.dR}, dC=+$${s.dC})`;
|
|
590
610
|
const context =
|
|
@@ -616,7 +636,7 @@ export async function learnAcrossEstate(repos) {
|
|
|
616
636
|
});
|
|
617
637
|
}
|
|
618
638
|
|
|
619
|
-
|
|
639
|
+
const result = {
|
|
620
640
|
reposWithLedgers,
|
|
621
641
|
reposWithDenials,
|
|
622
642
|
totalLedgerRuns,
|
|
@@ -627,6 +647,11 @@ export async function learnAcrossEstate(repos) {
|
|
|
627
647
|
noValueRules,
|
|
628
648
|
adrDrafts,
|
|
629
649
|
};
|
|
650
|
+
if (learningCache.size >= LEARNING_CACHE_MAX) {
|
|
651
|
+
learningCache.delete(learningCache.keys().next().value);
|
|
652
|
+
}
|
|
653
|
+
learningCache.set(digest, result);
|
|
654
|
+
return result;
|
|
630
655
|
}
|
|
631
656
|
|
|
632
657
|
/**
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Capped parallelism for estate-wide work (review 0003 Q38).
|
|
2
|
+
//
|
|
3
|
+
// Two callers need it - row building and estate learning - and a third copy of
|
|
4
|
+
// the same worker pool would be the defect ADR-002 names, so it lives here.
|
|
5
|
+
// Order-preserving: results land at their input index, because both callers
|
|
6
|
+
// diff their output between runs and completion order would scramble it.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How many estate items to work on at once.
|
|
10
|
+
*
|
|
11
|
+
* The work is process-spawn bound (git, file reads), so unbounded parallelism
|
|
12
|
+
* is a burst of subprocesses with no speedup, and sequential work is what took
|
|
13
|
+
* `compare` 48 s over MCP for 17 repos. Four overlaps the wait while keeping
|
|
14
|
+
* the burst bounded.
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
17
|
+
|
|
18
|
+
export function resolveConcurrency(flags = {}) {
|
|
19
|
+
const raw = flags?.concurrency ?? process.env.SIMT_CONCURRENCY ?? DEFAULT_CONCURRENCY;
|
|
20
|
+
const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
|
|
21
|
+
return Number.isFinite(n) && n >= 1 ? Math.floor(n) : DEFAULT_CONCURRENCY;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Map `items` through async `fn` with at most `concurrency` in flight.
|
|
26
|
+
*
|
|
27
|
+
* @param {unknown[]} items
|
|
28
|
+
* @param {(item: unknown, index: number) => Promise<unknown>} fn
|
|
29
|
+
* @param {{concurrency?: number, signal?: AbortSignal|null, onItem?: ((done: number, total: number) => void)|null}} options
|
|
30
|
+
* @returns {Promise<unknown[]>} results in input order
|
|
31
|
+
* @throws the first worker failure, or a CANCELLED error when `signal` aborts
|
|
32
|
+
*/
|
|
33
|
+
export async function mapCapped(items, fn, { concurrency = DEFAULT_CONCURRENCY, signal = null, onItem = null } = {}) {
|
|
34
|
+
const list = [...(items ?? [])];
|
|
35
|
+
const out = new Array(list.length);
|
|
36
|
+
const cap = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : DEFAULT_CONCURRENCY;
|
|
37
|
+
let next = 0;
|
|
38
|
+
let done = 0;
|
|
39
|
+
async function worker() {
|
|
40
|
+
while (next < list.length) {
|
|
41
|
+
if (signal?.aborted) throw Object.assign(new Error('cancelled by client'), { code: 'CANCELLED' });
|
|
42
|
+
const i = next++;
|
|
43
|
+
out[i] = await fn(list[i], i);
|
|
44
|
+
done += 1;
|
|
45
|
+
if (onItem) onItem(done, list.length);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await Promise.all(Array.from({ length: Math.min(cap, list.length) }, () => worker()));
|
|
49
|
+
return out;
|
|
50
|
+
}
|
package/src/compare/row.mjs
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
// fact per line in a stable field order so two runs diff cleanly; a row that
|
|
17
17
|
// reordered its columns would make every comparison between two runs unreadable.
|
|
18
18
|
|
|
19
|
+
import { mapCapped, DEFAULT_CONCURRENCY, resolveConcurrency } from './parallel.mjs';
|
|
19
20
|
import { checkTargets, PRIMARY_TARGET } from '../projection/project.mjs';
|
|
20
21
|
import { manifestDrift, readManifest } from '../projection/manifest.mjs';
|
|
21
22
|
import { loadRules, select } from '../projection/rules.mjs';
|
|
22
23
|
import { resolvePacks } from '../packs.mjs';
|
|
23
|
-
import { knownGates } from '../hooks/resolve.mjs';
|
|
24
24
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
25
25
|
import { readHooksState, wantsShim } from './hooksstate.mjs';
|
|
26
26
|
import { CLI } from '../name.mjs';
|
|
@@ -74,7 +74,8 @@ function packageEntry(f) {
|
|
|
74
74
|
return { path: f.file, profiles: f.profiles ?? [], status: f.status, detail: f.detail ?? null };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export async function buildRow(path, context) {
|
|
77
|
+
export async function buildRow(path, context) {
|
|
78
|
+
const name = path.split('/').filter(Boolean).pop() ?? path;
|
|
78
79
|
const problems = [];
|
|
79
80
|
const notes = [];
|
|
80
81
|
|
|
@@ -354,6 +355,29 @@ export async function buildRow(path, context) { const name = path.split('/').fi
|
|
|
354
355
|
return row;
|
|
355
356
|
}
|
|
356
357
|
|
|
358
|
+
export { DEFAULT_CONCURRENCY, resolveConcurrency } from './parallel.mjs';
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Every row, in input order, built with at most `concurrency` in flight.
|
|
362
|
+
*
|
|
363
|
+
* A thrown row is impossible - `buildRow` never rejects - so the first error
|
|
364
|
+
* aborts, because a pool that swallows the one failure mode its worker cannot
|
|
365
|
+
* produce is decoration. `onBatch(done, total)` fires after each row for MCP
|
|
366
|
+
* `notifications/progress`; `signal` aborts between rows for
|
|
367
|
+
* `notifications/cancelled`.
|
|
368
|
+
*/
|
|
369
|
+
export async function buildRows(
|
|
370
|
+
paths,
|
|
371
|
+
context,
|
|
372
|
+
{ concurrency = DEFAULT_CONCURRENCY, signal = null, onBatch = null } = {},
|
|
373
|
+
) {
|
|
374
|
+
return mapCapped(paths, (p) => buildRow(p, context), {
|
|
375
|
+
concurrency,
|
|
376
|
+
signal,
|
|
377
|
+
onItem: onBatch,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
357
381
|
function declaredGates(manifest) {
|
|
358
382
|
if (!manifest) return [];
|
|
359
383
|
return [...(manifest.gates?.preCommit ?? []), ...(manifest.gates?.prePush ?? [])];
|
|
@@ -396,4 +420,3 @@ export function summarise(rows, extra = {}) {
|
|
|
396
420
|
};
|
|
397
421
|
}
|
|
398
422
|
|
|
399
|
-
export { knownGates };
|
package/src/completions.mjs
CHANGED
|
@@ -125,6 +125,11 @@ function renderZsh({ top, groups }) {
|
|
|
125
125
|
'# source this after compinit: `source <(simt completions zsh)`.',
|
|
126
126
|
'_simt() {',
|
|
127
127
|
' local cur prev',
|
|
128
|
+
// compsys sets `words` and `CURRENT` before the function runs; without the
|
|
129
|
+
// assignment `cur` and `prev` stay empty, so the flag and value-flag cases
|
|
130
|
+
// below never match and zsh completes only positionals (review 0003 Q51).
|
|
131
|
+
' cur=${words[CURRENT]}',
|
|
132
|
+
' prev=${words[CURRENT-1]}',
|
|
128
133
|
' local -a top flags subs',
|
|
129
134
|
` top=( ${top.join(' ')} )`,
|
|
130
135
|
` flags=( ${FLAGS.join(' ')} )`,
|
package/src/context.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The per-run memo a command reads its repository through (review 0003 Q67).
|
|
2
|
+
//
|
|
3
|
+
// `check` did the same work twice in one run: `fingerprint(root)` at two sites, a
|
|
4
|
+
// manifest read that `manifestDrift` repeated, and a rules load that every consumer
|
|
5
|
+
// paid for again. Each fingerprint spawns `git ls-files` and walks the tree once per
|
|
6
|
+
// glob fact, so the second call was never cheap - it was invisible.
|
|
7
|
+
//
|
|
8
|
+
// The cache lives for one command, not for the process. `bench` writes manifests and
|
|
9
|
+
// rewrites trees mid-run; a process-lifetime memo would hand a benchmark the answer
|
|
10
|
+
// for a repository it had just changed, which is the one way a cache can be worse
|
|
11
|
+
// than no cache. A caller that wants a fresh answer makes a fresh context.
|
|
12
|
+
//
|
|
13
|
+
// A rejected computation is not cached: a transient failure (a git lock, a permission
|
|
14
|
+
// error) should not become the answer for the rest of the run, and every one of these
|
|
15
|
+
// readers is safe to retry.
|
|
16
|
+
|
|
17
|
+
import { fingerprint } from './resolve/fingerprint.mjs';
|
|
18
|
+
import { readManifest } from './projection/manifest.mjs';
|
|
19
|
+
import { loadRules, provenanceDigest } from './projection/rules.mjs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One repository, read once per question.
|
|
23
|
+
*
|
|
24
|
+
* Each accessor is a promise, and two calls with the same key share it rather than
|
|
25
|
+
* racing: the first caller to ask starts the work, and everyone else awaits it.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} root the repository this context answers for
|
|
28
|
+
*/
|
|
29
|
+
export function repoContext(root) {
|
|
30
|
+
const cache = new Map();
|
|
31
|
+
const counts = { hits: 0, misses: 0 };
|
|
32
|
+
|
|
33
|
+
const once = (key, compute) => {
|
|
34
|
+
if (cache.has(key)) {
|
|
35
|
+
counts.hits += 1;
|
|
36
|
+
return cache.get(key);
|
|
37
|
+
}
|
|
38
|
+
counts.misses += 1;
|
|
39
|
+
const pending = Promise.resolve()
|
|
40
|
+
.then(compute)
|
|
41
|
+
.catch((error) => {
|
|
42
|
+
// Not cached, or one failed read would answer every later caller.
|
|
43
|
+
cache.delete(key);
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
46
|
+
cache.set(key, pending);
|
|
47
|
+
return pending;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
root,
|
|
52
|
+
/** What the cache did, for a test or a measurement - never printed by a command. */
|
|
53
|
+
get stats() {
|
|
54
|
+
return { ...counts, entries: cache.size };
|
|
55
|
+
},
|
|
56
|
+
fingerprint: () => once('fingerprint', () => fingerprint(root)),
|
|
57
|
+
// The legacy shape, because that is what the commands print: `error` is the
|
|
58
|
+
// unreadable-file case and every caller already renders it. Memoising the loader's
|
|
59
|
+
// `problems` array here instead gave doctor `undefined` where it tests for null.
|
|
60
|
+
manifest: () => once('manifest', () => readManifest(root)),
|
|
61
|
+
rules: () => once('rules', () => loadRules(root)),
|
|
62
|
+
digest: () => once('digest', () => provenanceDigest()),
|
|
63
|
+
};
|
|
64
|
+
}
|