simt-dev 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- 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-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 +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- 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 +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- 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 +16 -11
- 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 +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- 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/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- 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/manifest.mjs +10 -435
- 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 +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- 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/{bench/process.mjs → sys/proc.mjs} +28 -5
- 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 +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- 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/src/bench/tasks.mjs +0 -36
package/src/index/store.mjs
CHANGED
|
@@ -8,10 +8,16 @@ import { execFile, spawn } from 'node:child_process';
|
|
|
8
8
|
import { stat } from 'node:fs/promises';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { git as gitRaw } from '../sys/git.mjs';
|
|
12
|
+
|
|
11
13
|
import { readManifest } from '../projection/manifest.mjs';
|
|
12
14
|
import { readLocalConfig } from '../local/config.mjs';
|
|
13
15
|
import { localEmbed } from '../local/client.mjs';
|
|
14
16
|
import { extract, resolveImports } from './extract.mjs';
|
|
17
|
+
// Q70: the extension-to-language map is the records' `languages[]` (ADR-013). `canonical`
|
|
18
|
+
// names the extensions that RESOLVE to a language (JavaScript claims `.ts` for extraction
|
|
19
|
+
// but not for resolution), so a record's extraction and resolution cannot desynchronize.
|
|
20
|
+
import { languageResolver } from '../ecosystem/records.mjs';
|
|
15
21
|
import {
|
|
16
22
|
FTS_SCHEMA,
|
|
17
23
|
FTS_TRI_SCHEMA,
|
|
@@ -34,24 +40,32 @@ export { JSONL_FILE, SQLITE_FILE, decodeVectorBlob, encodeVectorBlob };
|
|
|
34
40
|
|
|
35
41
|
const MARKERS = ['package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'pom.xml'];
|
|
36
42
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
// A tracked blob larger than this is recorded with its size and counted in
|
|
44
|
+
// `skipped`, not streamed and tokenized: a 65 MiB file is indexed as a row, and
|
|
45
|
+
// tokenizing it would buy nothing (Q76). The cap sits under that file on purpose
|
|
46
|
+
// so the 65 MiB test exercises the skip, not the tokenize path.
|
|
47
|
+
const BLOB_CAP = 16 * 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
const LANG = languageResolver();
|
|
42
50
|
|
|
43
51
|
/** `sqlite` when this process can open a database and create an FTS5 table. */
|
|
52
|
+
// Probed once per process: the check builds both FTS schemas in a memory
|
|
53
|
+
// database, and every read path used to pay for that (Q76).
|
|
54
|
+
let backendProbe;
|
|
44
55
|
async function probeBackend() {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
backendProbe ??= (async () => {
|
|
57
|
+
try {
|
|
58
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
59
|
+
const db = new DatabaseSync(':memory:');
|
|
60
|
+
db.exec(FTS_SCHEMA);
|
|
61
|
+
db.exec(FTS_TRI_SCHEMA);
|
|
62
|
+
db.close();
|
|
63
|
+
return 'sqlite';
|
|
64
|
+
} catch {
|
|
65
|
+
return 'jsonl';
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
return backendProbe;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export function ftsKindOf(filePath, lang = '') {
|
|
@@ -224,7 +238,10 @@ export async function reindex(root, opts = {}) {
|
|
|
224
238
|
changed.push(entry);
|
|
225
239
|
}
|
|
226
240
|
const sizes = await blobSizes(root, changed.map((entry) => entry.blob));
|
|
227
|
-
|
|
241
|
+
// Over-cap blobs are recorded with their size and counted in `skipped`, not
|
|
242
|
+
// streamed and tokenized: their row is written, their text is never read.
|
|
243
|
+
const textBlobs = changed.map((entry) => entry.blob).filter((blob) => (sizes.get(blob) ?? 0) <= BLOB_CAP);
|
|
244
|
+
const texts = await blobTexts(root, textBlobs);
|
|
228
245
|
const adapter = await resolveAdapter(root, opts);
|
|
229
246
|
const paths = new Set(listed.map((entry) => entry.path));
|
|
230
247
|
const next = [];
|
|
@@ -383,7 +400,14 @@ function withReadError(rows, error) {
|
|
|
383
400
|
export async function readFiles(root, opts = {}) {
|
|
384
401
|
const backend = opts.backend ?? await probeBackend();
|
|
385
402
|
if (backend === 'sqlite') {
|
|
386
|
-
|
|
403
|
+
// The v5 layout keys files as `sources` rows; the git blob sha is `revision`
|
|
404
|
+
// and the package directory rides in `props`, which the v4 `files` table
|
|
405
|
+
// named `pkg`.
|
|
406
|
+
const { rows, error } = await readSqliteRows(
|
|
407
|
+
root,
|
|
408
|
+
`select s.path as path, s.revision as blob, s.lang as lang, s.bytes as bytes,
|
|
409
|
+
json_extract(s.props, '$.pkg') as pkg from sources s where s.kind = 'blob' order by s.path`,
|
|
410
|
+
);
|
|
387
411
|
return withReadError(rows, error);
|
|
388
412
|
}
|
|
389
413
|
const { rows, error } = await readJsonl(root, 'files');
|
|
@@ -394,7 +418,14 @@ export async function readFiles(root, opts = {}) {
|
|
|
394
418
|
export async function readSymbols(root, opts = {}) {
|
|
395
419
|
const backend = opts.backend ?? await probeBackend();
|
|
396
420
|
if (backend === 'sqlite') {
|
|
397
|
-
const { rows, error } = await readSqliteRows(
|
|
421
|
+
const { rows, error } = await readSqliteRows(
|
|
422
|
+
root,
|
|
423
|
+
`select s.path as path, n.name as name, json_extract(n.props, '$.kind') as kind,
|
|
424
|
+
n.line as line, json_extract(n.props, '$.sig') as sig,
|
|
425
|
+
json_extract(n.props, '$.exported') as exported
|
|
426
|
+
from nodes n join sources s on s.id = n.source_id and s.kind = 'blob'
|
|
427
|
+
where n.kind = 'symbol' order by s.path, n.line, n.name, n.kind`,
|
|
428
|
+
);
|
|
398
429
|
return withReadError(rows, error);
|
|
399
430
|
}
|
|
400
431
|
const { rows, error } = await readJsonl(root, 'symbols');
|
|
@@ -405,7 +436,14 @@ export async function readSymbols(root, opts = {}) {
|
|
|
405
436
|
export async function readEdges(root, opts = {}) {
|
|
406
437
|
const backend = opts.backend ?? await probeBackend();
|
|
407
438
|
if (backend === 'sqlite') {
|
|
408
|
-
|
|
439
|
+
// Paths come back from the node key (`file:<path>`) and the stable `dst_key`:
|
|
440
|
+
// node ids are regenerated when a file changes, so a path is the only key
|
|
441
|
+
// that survives a reindex of one endpoint.
|
|
442
|
+
const { rows, error } = await readSqliteRows(
|
|
443
|
+
root,
|
|
444
|
+
`select substr(ns.key, 6) as src, e.dst_key as dst, e.kind as kind, e.weight as weight
|
|
445
|
+
from edges e join nodes ns on ns.id = e.src_id order by src, dst, kind`,
|
|
446
|
+
);
|
|
409
447
|
return withReadError(rows, error);
|
|
410
448
|
}
|
|
411
449
|
const { rows, error } = await readJsonl(root, 'edges');
|
|
@@ -416,7 +454,11 @@ export async function readEdges(root, opts = {}) {
|
|
|
416
454
|
export async function readFts(root, opts = {}) {
|
|
417
455
|
const backend = opts.backend ?? await probeBackend();
|
|
418
456
|
if (backend === 'sqlite') {
|
|
419
|
-
const { rows, error } = await readSqliteRows(
|
|
457
|
+
const { rows, error } = await readSqliteRows(
|
|
458
|
+
root,
|
|
459
|
+
`select f.kind as kind, substr(n.key, 6) as ref, f.body as body
|
|
460
|
+
from fts f join nodes n on n.id = f.node_id order by ref, kind, f.rowid`,
|
|
461
|
+
);
|
|
420
462
|
return withReadError(rows, error);
|
|
421
463
|
}
|
|
422
464
|
const { rows, error } = await readJsonl(root, 'fts');
|
|
@@ -427,7 +469,12 @@ export async function readFts(root, opts = {}) {
|
|
|
427
469
|
export async function readEmbeddings(root, opts = {}) {
|
|
428
470
|
const backend = opts.backend ?? await probeBackend();
|
|
429
471
|
if (backend === 'sqlite') {
|
|
430
|
-
const { rows: raw, error } = await readSqliteRows(
|
|
472
|
+
const { rows: raw, error } = await readSqliteRows(
|
|
473
|
+
root,
|
|
474
|
+
`select substr(n.key, 6) as path, s.revision as blob, v.model as model, v.dims as dims, v.vec as vec
|
|
475
|
+
from vectors v join nodes n on n.id = v.node_id
|
|
476
|
+
join sources s on s.id = n.source_id and s.kind = 'blob' order by path`,
|
|
477
|
+
);
|
|
431
478
|
const rows = raw.map((row) => {
|
|
432
479
|
const vec = decodeVectorBlob(row.vec);
|
|
433
480
|
return {
|
|
@@ -500,11 +547,15 @@ export async function searchFtsBm25(root, task, opts = {}) {
|
|
|
500
547
|
async function ftsBranch(root, table, terms) {
|
|
501
548
|
if (terms.length === 0) return { rows: [], error: null };
|
|
502
549
|
const matchExpr = terms.map((t) => `"${t}"`).join(' OR ');
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
550
|
+
// `fts` is the v5 node-keyed table: the path comes back through the node key.
|
|
551
|
+
// `fts_tri` keeps its v4 (kind, ref, body) shape for the CJK trigram path (Q43),
|
|
552
|
+
// so it is queried the old way.
|
|
553
|
+
const sql = table === 'fts'
|
|
554
|
+
? `select f.kind as kind, substr(n.key, 6) as path, -bm25(f) as score
|
|
555
|
+
from fts f join nodes n on n.id = f.node_id
|
|
556
|
+
where f match ? order by score desc, path asc limit ?`
|
|
557
|
+
: `select kind, ref as path, -bm25(${table}) as score from ${table} where ${table} match ? order by score desc, path asc limit ?`;
|
|
558
|
+
const { rows, error } = await readSqliteRows(root, sql, [matchExpr, FTS_LIMIT]);
|
|
508
559
|
return {
|
|
509
560
|
rows: rows.map((row) => ({ kind: row.kind, path: row.path, score: Number(row.score) || 0 })),
|
|
510
561
|
error,
|
|
@@ -590,20 +641,10 @@ export async function packageOf(root, filePath, cache) {
|
|
|
590
641
|
}
|
|
591
642
|
}
|
|
592
643
|
|
|
593
|
-
|
|
594
|
-
return new Promise((resolve, reject) => {
|
|
595
|
-
const child = execFile('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
596
|
-
if (error) reject(Object.assign(error, { stdout, stderr }));
|
|
597
|
-
else resolve(stdout);
|
|
598
|
-
});
|
|
599
|
-
if (input !== undefined) {
|
|
600
|
-
child.stdin.end(input);
|
|
601
|
-
}
|
|
602
|
-
});
|
|
603
|
-
}
|
|
644
|
+
|
|
604
645
|
|
|
605
646
|
async function lsFiles(root) {
|
|
606
|
-
const stdout = await
|
|
647
|
+
const stdout = await gitRaw(root, ['ls-files', '-s', '-z']);
|
|
607
648
|
const rows = [];
|
|
608
649
|
for (const rec of stdout.split('\0')) {
|
|
609
650
|
if (rec === '') continue;
|
|
@@ -738,7 +779,9 @@ async function blobSizes(root, blobs) {
|
|
|
738
779
|
const unique = [...new Set(blobs)];
|
|
739
780
|
const sizes = new Map();
|
|
740
781
|
if (unique.length === 0) return sizes;
|
|
741
|
-
const stdout = await
|
|
782
|
+
const stdout = await gitRaw(root, ['cat-file', '--batch-check=%(objectname) %(objectsize)'], {
|
|
783
|
+
input: `${unique.join('\n')}\n`,
|
|
784
|
+
});
|
|
742
785
|
for (const line of stdout.split('\n')) {
|
|
743
786
|
if (line === '') continue;
|
|
744
787
|
const sp = line.indexOf(' ');
|
package/src/index/tests.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// the tree is not a guess. Co-change is a different kind: files that land in
|
|
7
7
|
// the same commit are not, by that fact, a test.
|
|
8
8
|
|
|
9
|
-
import { isTestPath } from '../
|
|
9
|
+
import { isTestPath } from '../testing/paths.mjs';
|
|
10
10
|
import { extract, resolveImports } from './extract.mjs';
|
|
11
|
+
import { cmp } from '../util/sort.mjs';
|
|
11
12
|
|
|
12
13
|
export { scriptSpecifiers } from './extract.mjs';
|
|
13
14
|
|
|
@@ -251,11 +252,6 @@ function byEdge(a, b) {
|
|
|
251
252
|
return cmp(a.kind, b.kind) || cmp(a.src, b.src) || cmp(a.dst, b.dst);
|
|
252
253
|
}
|
|
253
254
|
|
|
254
|
-
function cmp(a, b) {
|
|
255
|
-
if (a < b) return -1;
|
|
256
|
-
if (a > b) return 1;
|
|
257
|
-
return 0;
|
|
258
|
-
}
|
|
259
255
|
|
|
260
256
|
/** Imports in a file, resolved against the tracked tree. One path now: `extract`
|
|
261
257
|
* reads scripts with `scriptSpecifiers`, so gate, impact and the index agree on
|
package/src/journal.mjs
CHANGED
|
@@ -41,17 +41,15 @@
|
|
|
41
41
|
// a second `undo` naturally targets the set before it, and the untouched-check
|
|
42
42
|
// refuses when the two sets overlap on a file the first undo already restored.
|
|
43
43
|
|
|
44
|
-
import { execFile } from 'node:child_process';
|
|
45
44
|
import { createHash } from 'node:crypto';
|
|
46
45
|
import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
47
46
|
import { dirname, join, relative, sep } from 'node:path';
|
|
48
|
-
import { promisify } from 'node:util';
|
|
49
47
|
|
|
48
|
+
import { noteEvent } from './state/events.mjs';
|
|
50
49
|
import { STATE_DIR } from './name.mjs';
|
|
51
50
|
import { LOCAL_DIR } from './state.mjs';
|
|
52
51
|
import { HOOKS_DIR } from './hooks/shim.mjs';
|
|
53
|
-
|
|
54
|
-
const exec = promisify(execFile);
|
|
52
|
+
import { git, gitText } from './sys/git.mjs';
|
|
55
53
|
|
|
56
54
|
const JOURNAL_DIR = join(LOCAL_DIR, 'journal');
|
|
57
55
|
const JOURNAL_DIR_LEGACY = '.simt/journal';
|
|
@@ -222,8 +220,7 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
222
220
|
/** Read one git config value in a repo, or null when unset. */
|
|
223
221
|
export async function readGitValue(root, key) {
|
|
224
222
|
try {
|
|
225
|
-
|
|
226
|
-
return stdout.replace(/\n$/, '');
|
|
223
|
+
return await gitText(root, ['config', '--get', key]);
|
|
227
224
|
} catch (error) {
|
|
228
225
|
if (error.code === 1) return null;
|
|
229
226
|
throw error;
|
|
@@ -246,9 +243,9 @@ export async function journaledGitConfig(root, acc, key, value) {
|
|
|
246
243
|
const before = await readGitValue(root, key);
|
|
247
244
|
if (before === value) return null;
|
|
248
245
|
if (value === null) {
|
|
249
|
-
await
|
|
246
|
+
await git(root, ['config', '--unset', key]);
|
|
250
247
|
} else {
|
|
251
|
-
await
|
|
248
|
+
await git(root, ['config', key, value]);
|
|
252
249
|
}
|
|
253
250
|
const entry = { kind: 'git-config', key, before, after: value };
|
|
254
251
|
acc.push(entry);
|
|
@@ -407,6 +404,14 @@ export async function commitJournal(root, command, acc, { at = Date.now() } = {}
|
|
|
407
404
|
const tmp = join(dir, `${id}.tmp-${process.pid}`);
|
|
408
405
|
await writeFile(tmp, body, 'utf8');
|
|
409
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
|
+
});
|
|
410
415
|
return id;
|
|
411
416
|
}
|
|
412
417
|
|
|
@@ -528,9 +533,7 @@ function entryBytes(entry) {
|
|
|
528
533
|
/** Restore one entry: bytes and permission bits for files, the value for config. */
|
|
529
534
|
async function restoreEntry(root, entry) {
|
|
530
535
|
if (entry.kind === 'git-config') {
|
|
531
|
-
await
|
|
532
|
-
cwd: root,
|
|
533
|
-
});
|
|
536
|
+
await git(root, entry.before === null ? ['config', '--unset', entry.key] : ['config', entry.key, entry.before]);
|
|
534
537
|
return;
|
|
535
538
|
}
|
|
536
539
|
const abs = join(root, entry.path);
|
|
@@ -610,5 +613,7 @@ export async function undoLastJournalSet(root) {
|
|
|
610
613
|
const [id] = await listJournalSets(root);
|
|
611
614
|
if (!id) return { empty: true };
|
|
612
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 } });
|
|
613
618
|
return { set: id, ...result };
|
|
614
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).
|