@quolu/lattice 0.26.0 → 0.28.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/package.json +6 -3
- package/src/cli-help.mjs +20 -1
- package/src/isolation-runner.mjs +21 -2
- package/src/runtime-cli.mjs +81 -0
- package/src/runtime-hold-recompile.mjs +4 -13
- package/src/runtime-seam-resolve.mjs +283 -0
- package/src/seam-apply.mjs +0 -0
- package/src/seam-commit-shared.mjs +22 -0
- package/src/seam-commit-transform.mjs +81 -0
- package/src/seam-commit.mjs +0 -0
- package/src/seam-ref.mjs +33 -0
- package/src/seam-rewrite.mjs +19 -1
- package/src/todo-cli.mjs +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -60,7 +60,10 @@
|
|
|
60
60
|
"test": "node scripts/run-product-tests.mjs",
|
|
61
61
|
"test:sensor": "npm --prefix sensor test",
|
|
62
62
|
"check": "node scripts/check-syntax.mjs",
|
|
63
|
-
"ci": "npm run test && npm run test:sensor && npm run check && npm run verify:todo-store",
|
|
64
|
-
"verify:todo-store": "node bin/lattice.mjs todo verify --json"
|
|
63
|
+
"ci": "npm run test && npm run test:sensor && npm run check && npm run check:cli-surface && npm run check:open-questions && npm run check:reachability && npm run verify:todo-store",
|
|
64
|
+
"verify:todo-store": "node bin/lattice.mjs todo verify --json",
|
|
65
|
+
"check:cli-surface": "node scripts/verify-cli-surface.mjs",
|
|
66
|
+
"check:open-questions": "node scripts/verify-open-questions.mjs",
|
|
67
|
+
"check:reachability": "node scripts/verify-product-reachability.mjs"
|
|
65
68
|
}
|
|
66
69
|
}
|
package/src/cli-help.mjs
CHANGED
|
@@ -44,6 +44,10 @@ Commands:
|
|
|
44
44
|
close --run .lattice/runs/<id>
|
|
45
45
|
abandon --run .lattice/runs/<id> --reason <reason>
|
|
46
46
|
list --json
|
|
47
|
+
seam resolve --run .lattice/runs/<id> --finding <digest> --input <request.json>
|
|
48
|
+
# 記録済み競合を隔離worktreeで実際に変換し、五条件を通ればseam splitと後継baseを返す。
|
|
49
|
+
# 入力(lattice.runtime_seam_request.v1)へ書くのは、係争fileの中で各TODOが触るsymbolと
|
|
50
|
+
# 新しい面の名前だけ。branchは動かさないので、後継baseへ進めるかは呼び出し側が決める。
|
|
47
51
|
`,
|
|
48
52
|
event: `Usage: lattice event verify --run .lattice/runs/<id>
|
|
49
53
|
`,
|
|
@@ -119,6 +123,8 @@ registerはLATTICE_BRIDGE_REGISTRAR_SSH_HOSTとLATTICE_BRIDGE_REGISTRAR_SCRIPT
|
|
|
119
123
|
});
|
|
120
124
|
|
|
121
125
|
const SUBCOMMAND_USAGE = Object.freeze({
|
|
126
|
+
status: 'status --json',
|
|
127
|
+
'session-context': 'session-context --json',
|
|
122
128
|
'plan create': 'plan create --input <file> | --schema --json | --schema-version <2|3> --json',
|
|
123
129
|
'plan compile': 'plan compile --request <request.json> | --schema --json',
|
|
124
130
|
'plan verify': 'plan verify --request <request.json> --plan <plan.json>',
|
|
@@ -131,6 +137,13 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
131
137
|
'run close': 'run close --run .lattice/runs/<id>',
|
|
132
138
|
'run abandon': 'run abandon --run .lattice/runs/<id> --reason <reason>',
|
|
133
139
|
'run list': 'run list --json',
|
|
140
|
+
'run activate': 'run activate --run .lattice/runs/<id>',
|
|
141
|
+
'run conflict': 'run conflict --run .lattice/runs/<id> --finding <digest>',
|
|
142
|
+
'run hold': 'run hold --run .lattice/runs/<id> --finding <digest>',
|
|
143
|
+
'run recompile': 'run recompile --run .lattice/runs/<id> --input <recompile-request.json>',
|
|
144
|
+
'run reprocess': 'run reprocess --run .lattice/runs/<id>',
|
|
145
|
+
'run finding record': 'run finding record --run .lattice/runs/<id> --checkpoint <digest> --input <candidate.json>',
|
|
146
|
+
'run seam resolve': 'run seam resolve --run .lattice/runs/<id> --finding <digest> --input <seam-request.json>',
|
|
134
147
|
'event verify': 'event verify --run .lattice/runs/<id>',
|
|
135
148
|
'todo status': 'todo status [--json]',
|
|
136
149
|
'todo bindings': 'todo bindings [--plan <key>] [--json]',
|
|
@@ -155,6 +168,7 @@ const SUBCOMMAND_USAGE = Object.freeze({
|
|
|
155
168
|
'todo revise': 'todo revise --plan <key> --input <file>',
|
|
156
169
|
'todo revise-phase': 'todo revise-phase --plan <key> --input <file>',
|
|
157
170
|
'todo revise-set': 'todo revise-set --input <file>',
|
|
171
|
+
'todo migrate': 'todo migrate --input <extraction.json>',
|
|
158
172
|
'sensor init': 'sensor init [path] --json',
|
|
159
173
|
'sensor sync': 'sensor sync [path] --json',
|
|
160
174
|
'runtime-errors snapshot': 'runtime-errors snapshot [--after-cursor <n>] [--limit <n>] --json',
|
|
@@ -192,5 +206,10 @@ export function renderCliHelp(argv) {
|
|
|
192
206
|
return usage === undefined ? null : `Usage: lattice ${usage}\n`;
|
|
193
207
|
}
|
|
194
208
|
const namespace = requestedNamespace(argv);
|
|
195
|
-
|
|
209
|
+
if (namespace === null) return null;
|
|
210
|
+
// namespaceを持たない1語コマンド(status等)は、SUBCOMMAND_USAGEへ落ちる。
|
|
211
|
+
// 落とさないと「存在するのに使い方を知る手段が無い」コマンドになる。
|
|
212
|
+
if (NAMESPACE_HELP[namespace] !== undefined) return NAMESPACE_HELP[namespace];
|
|
213
|
+
const usage = SUBCOMMAND_USAGE[namespace];
|
|
214
|
+
return usage === undefined ? null : `Usage: lattice ${usage}\n`;
|
|
196
215
|
}
|
package/src/isolation-runner.mjs
CHANGED
|
@@ -311,7 +311,15 @@ export async function runIsolatedTransform({ repoRoot, baseRef, allowedPaths, tr
|
|
|
311
311
|
const mountedEntries = mounts.map(({ entry }) => entry);
|
|
312
312
|
|
|
313
313
|
const sourceState = await captureSourceState(repoRoot);
|
|
314
|
-
if (sourceState.visibleStatus.length > 0)
|
|
314
|
+
if (sourceState.visibleStatus.length > 0) {
|
|
315
|
+
// どのpathが汚しているかを言わないと、呼び出したAIは何を片付ければよいか分からない。
|
|
316
|
+
// 実運用で、このコマンドへ渡す入力fileそのものが木を汚して詰まった。
|
|
317
|
+
// visibleStatusはNUL区切りのBufferである。行として扱うと空文字が並ぶ。
|
|
318
|
+
const entries = statusPaths(sourceState.visibleStatus).slice(0, 20);
|
|
319
|
+
throw new Error('source repository must be clean :: '
|
|
320
|
+
+ `${entries.join(' | ')} :: 変換は既知のbaseに対して測る。commitするか、`
|
|
321
|
+
+ 'gitが無視するpathへ退避してから再実行する');
|
|
322
|
+
}
|
|
315
323
|
const baseSha = (await run('git', ['rev-parse', '--verify', `${baseRef}^{commit}`], { cwd: repoRoot })).stdout.toString('utf8').trim();
|
|
316
324
|
const worktreePath = await mkdtemp(path.join(os.tmpdir(), 'lattice-isolated-transform-'));
|
|
317
325
|
let added = false;
|
|
@@ -349,7 +357,18 @@ export async function runIsolatedTransform({ repoRoot, baseRef, allowedPaths, tr
|
|
|
349
357
|
verifications.push(verificationReceipt(verifier, verification, 'passed'));
|
|
350
358
|
} catch (error) {
|
|
351
359
|
verifications.push(verificationReceipt(verifier, error, 'failed'));
|
|
352
|
-
|
|
360
|
+
// どのverifierがなぜ落ちたかを載せる。commandだけ返すと、五条件の棄却理由が
|
|
361
|
+
// 「focused testが落ちた」で止まり、原因を追う手段が無くなる。
|
|
362
|
+
// stdout/stderrはBufferで、空でもtruthyになる。`||`で繋ぐとstdoutへ落ちない。
|
|
363
|
+
// node --testは失敗をstdoutへ書くので、それを取り落とすと理由が消える。
|
|
364
|
+
const streams = [error?.stderr, error?.stdout]
|
|
365
|
+
.map((value) => (value === undefined ? '' : String(value)))
|
|
366
|
+
.filter((value) => value.trim().length > 0);
|
|
367
|
+
const detail = (streams[0] ?? '')
|
|
368
|
+
.split('\n').filter((line) => line.trim().length > 0).slice(-6).join(' | ').slice(0, 600);
|
|
369
|
+
throw new Error(`verifier failed (${error.signal ?? error.code}):`
|
|
370
|
+
+ ` ${[verifier.command, ...verifier.args].join(' ')}`
|
|
371
|
+
+ (detail.length > 0 ? ` :: ${detail}` : ''));
|
|
353
372
|
}
|
|
354
373
|
await assertSnapshotUnchanged(worktreePath, baseSha, allowedPaths, snapshot, 'verifier', mountedEntries);
|
|
355
374
|
}
|
package/src/runtime-cli.mjs
CHANGED
|
@@ -15,10 +15,15 @@ import {
|
|
|
15
15
|
writeFile,
|
|
16
16
|
} from 'node:fs/promises';
|
|
17
17
|
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
|
|
19
20
|
import { canonicalizeArtifact, digestArtifact } from './artifact-contracts.mjs';
|
|
20
21
|
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
21
22
|
import { detectCheckpointFindings } from './runtime-diff-observer.mjs';
|
|
23
|
+
import {
|
|
24
|
+
buildRuntimeSeamResolution, readRuntimeFindingRecord, resolveRuntimeSeam,
|
|
25
|
+
validateRuntimeSeamRequest, verifySeamSplitSuccessor,
|
|
26
|
+
} from './runtime-seam-resolve.mjs';
|
|
22
27
|
import {
|
|
23
28
|
compileRuntimePlanV1,
|
|
24
29
|
evidenceFromCollectedOutcomes,
|
|
@@ -995,6 +1000,51 @@ async function runStart({ requestPath, executorAdapter, cwd, stdout }) {
|
|
|
995
1000
|
return 0;
|
|
996
1001
|
}
|
|
997
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* 記録済み競合findingを、実際の変換で解消する(請求項8・ADR 0137〜0141)。
|
|
1005
|
+
*
|
|
1006
|
+
* 事前宣言されたtreatmentが無い競合は、これが無い間ずっと意図的直列へ退化していた。
|
|
1007
|
+
* 隔離worktreeで変換し、五条件(ADR 0138)を通り、確定できた時だけseam splitを返す。
|
|
1008
|
+
*
|
|
1009
|
+
* branchは動かさない。返す`successor_base_sha`へ進めるかどうかは操作するAIが決める——
|
|
1010
|
+
* 静的側の`land`と同じ責務分担であり、Latticeは変換・検証・記録と、後継baseの検査を持つ。
|
|
1011
|
+
*/
|
|
1012
|
+
async function runSeamResolve({ runDir, repoRoot, findingDigest, requestPath, stdout }) {
|
|
1013
|
+
const committed = await readCommittedEpochStore(runDir);
|
|
1014
|
+
if (committed === null) {
|
|
1015
|
+
throw new CliContractError('RUN_NOT_MANAGED', 'runがmanaged storeへactivateされていない');
|
|
1016
|
+
}
|
|
1017
|
+
const declaration = await readBoundedJson(requestPath, 'runtime seam request');
|
|
1018
|
+
if (!validateRuntimeSeamRequest(declaration)) {
|
|
1019
|
+
throw new CliContractError('INVALID_SEAM_REQUEST', 'lattice.runtime_seam_request.v1として不正');
|
|
1020
|
+
}
|
|
1021
|
+
if (declaration.run_id !== committed.meta.run_id) {
|
|
1022
|
+
throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のrun_idがrun storeと一致しない');
|
|
1023
|
+
}
|
|
1024
|
+
if (declaration.finding_digest !== findingDigest) {
|
|
1025
|
+
throw new CliContractError('INVALID_SEAM_REQUEST', '宣言のfinding_digestが--findingと一致しない');
|
|
1026
|
+
}
|
|
1027
|
+
const found = await readRuntimeFindingRecord({
|
|
1028
|
+
runDir, findingDigest, planEpoch: committed.pointer.plan_epoch,
|
|
1029
|
+
});
|
|
1030
|
+
if (found.record === null) throw new CliContractError('STALE_FINDING', found.reason);
|
|
1031
|
+
|
|
1032
|
+
const resolved = await resolveRuntimeSeam({
|
|
1033
|
+
repoRoot,
|
|
1034
|
+
runDir,
|
|
1035
|
+
findingRecord: found.record,
|
|
1036
|
+
bundle: committed.bundle,
|
|
1037
|
+
declaration,
|
|
1038
|
+
latticeBin: fileURLToPath(new URL('../bin/lattice.mjs', import.meta.url)),
|
|
1039
|
+
compiledAt: canonicalNow(),
|
|
1040
|
+
});
|
|
1041
|
+
const resolution = buildRuntimeSeamResolution({
|
|
1042
|
+
runId: committed.meta.run_id, findingDigest, resolved,
|
|
1043
|
+
});
|
|
1044
|
+
stdout.write(`${JSON.stringify(resolution)}\n`);
|
|
1045
|
+
return resolution.lane === 'seam_transform' ? 0 : 1;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
998
1048
|
async function runObserve({ runDir, stdout }) {
|
|
999
1049
|
const { events } = await readRunStore(runDir);
|
|
1000
1050
|
const chain = verifyRunEventChain({ events });
|
|
@@ -2068,6 +2118,27 @@ export async function runManagedSupervisorDaemon({
|
|
|
2068
2118
|
newPlan.conflicts.sort((left, right) => `${left.todo_ids.join('\0')}\0${left.resource_id}`
|
|
2069
2119
|
.localeCompare(`${right.todo_ids.join('\0')}\0${right.resource_id}`));
|
|
2070
2120
|
}
|
|
2121
|
+
if (recompileRequest.mode === 'seam_split') {
|
|
2122
|
+
// 変換を含まないbaseを指したseam_splitを通さない(ADR 0141)。splitが新しい面の
|
|
2123
|
+
// 所有を宣言するのに後継treeにそのfileが無い、という状態を作らせない。
|
|
2124
|
+
const predecessorBaseSha = active.bundle.request.repo.base_sha;
|
|
2125
|
+
const successorBaseSha = recompileRequest.successor_request.repo.base_sha;
|
|
2126
|
+
const ancestry = await runGit(
|
|
2127
|
+
['merge-base', '--is-ancestor', predecessorBaseSha, successorBaseSha], repoRoot,
|
|
2128
|
+
);
|
|
2129
|
+
const verdict = verifySeamSplitSuccessor({
|
|
2130
|
+
split: recompileRequest.seam_split,
|
|
2131
|
+
predecessorBaseSha,
|
|
2132
|
+
successorBaseSha,
|
|
2133
|
+
successorIsDescendant: ancestry.code === 0,
|
|
2134
|
+
successorConflicts: newPlan.conflicts,
|
|
2135
|
+
successorWitness: recompileRequest.successor_request.manual_witness,
|
|
2136
|
+
});
|
|
2137
|
+
if (!verdict.ok) {
|
|
2138
|
+
throw new ManagedRuntimeError('SEAM_SPLIT_UNPROVEN',
|
|
2139
|
+
`後継baseがseam splitの主張を満たさない: ${verdict.reasons.join(', ')}`);
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2071
2142
|
newPlan.plan_digest = selfDigest(newPlan, 'plan_digest');
|
|
2072
2143
|
const executorPackets = buildExecutorPackets({ plan: newPlan, manifests: compiled.manifests });
|
|
2073
2144
|
|
|
@@ -3189,6 +3260,16 @@ export async function runRuntimeCli({ argv, cwd, stdout, stderr }) {
|
|
|
3189
3260
|
requestId: requestIdOverride,
|
|
3190
3261
|
});
|
|
3191
3262
|
};
|
|
3263
|
+
} else if (argv.length === 9
|
|
3264
|
+
&& argv[0] === 'run' && argv[1] === 'seam' && argv[2] === 'resolve'
|
|
3265
|
+
&& argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
|
|
3266
|
+
&& argv[5] === '--finding' && /^[0-9a-f]{64}$/u.test(argv[6])
|
|
3267
|
+
&& argv[7] === '--input' && typeof argv[8] === 'string' && argv[8].length > 0) {
|
|
3268
|
+
action = async () => {
|
|
3269
|
+
const { repoRoot, runDir } = await resolveRunStore(cwd, argv[4]);
|
|
3270
|
+
return runSeamResolve({ runDir, repoRoot, findingDigest: argv[6],
|
|
3271
|
+
requestPath: path.resolve(cwd, argv[8]), stdout });
|
|
3272
|
+
};
|
|
3192
3273
|
} else if (argv.length === 8
|
|
3193
3274
|
&& argv[0] === 'run' && argv[1] === 'finding' && argv[2] === 'record'
|
|
3194
3275
|
&& argv[3] === '--run' && typeof argv[4] === 'string' && argv[4].length > 0
|
|
@@ -65,7 +65,6 @@ function sha16(value) {
|
|
|
65
65
|
|
|
66
66
|
const WITNESS_KINDS = Object.freeze(['state', 'schema', 'invariant', 'effect', 'external_effect']);
|
|
67
67
|
const HEX_DIGEST = /^[0-9a-f]{64}$/u;
|
|
68
|
-
const HEX_SHA1 = /^[0-9a-f]{40}$/u;
|
|
69
68
|
const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
|
|
70
69
|
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
71
70
|
|
|
@@ -616,24 +615,20 @@ export function routeConflictTreatment(options = {}) {
|
|
|
616
615
|
* (content不変・epoch/plan refのみ更新)、hold TODOへは新plan_ref由来の
|
|
617
616
|
* 新context packetを発行し、旧contextを失効する。rebind/prepare ack、epoch pointer、
|
|
618
617
|
* controller ready/release ack、中央gate commit前にはepoch_rebound/intake_resumedを発行しない。
|
|
618
|
+
*
|
|
619
|
+
* baseはここでは動かさない。変換で構造が変わった時に後継baseを決めるのは後継run requestで
|
|
620
|
+
* あり、それが本当に変換を含むかは`verifySeamSplitSuccessor`が見る(ADR 0141)。
|
|
619
621
|
*/
|
|
620
622
|
export function recompileNextEpochPlan(options = {}) {
|
|
621
623
|
if (!exactRecord(options, [
|
|
622
624
|
'runId', 'request', 'plan', 'manifests', 'packets', 'events', 'holdDecision',
|
|
623
625
|
'additionalConflicts', 'recordedAt',
|
|
624
|
-
]) && !exactRecord(options, [
|
|
625
|
-
'runId', 'request', 'plan', 'manifests', 'packets', 'events', 'holdDecision',
|
|
626
|
-
'additionalConflicts', 'recordedAt', 'successorBaseSha',
|
|
627
626
|
])) {
|
|
628
627
|
fail('recompileNextEpochPlan optionsがexact shapeでない');
|
|
629
628
|
}
|
|
630
629
|
const {
|
|
631
630
|
runId, request, plan, manifests, packets, events, holdDecision, additionalConflicts, recordedAt,
|
|
632
|
-
successorBaseSha = null,
|
|
633
631
|
} = options;
|
|
634
|
-
if (successorBaseSha !== null && !HEX_SHA1.test(successorBaseSha)) {
|
|
635
|
-
fail('successorBaseShaがgit shaでない');
|
|
636
|
-
}
|
|
637
632
|
if (!validateRuntimePlan(plan)) fail('planがruntime_plan.v1 contractを満たさない');
|
|
638
633
|
if (!validateHoldDecision(holdDecision)) fail('holdDecisionがcontractを満たさない');
|
|
639
634
|
if (!Array.isArray(additionalConflicts)) fail('additionalConflictsがarrayではない');
|
|
@@ -681,11 +676,7 @@ export function recompileNextEpochPlan(options = {}) {
|
|
|
681
676
|
plan_ref: newPlanRef,
|
|
682
677
|
plan_epoch: newEpoch,
|
|
683
678
|
request_digest: request.request_digest,
|
|
684
|
-
|
|
685
|
-
// 旧baseのまま再開させると、splitが所有を宣言した新pathがworktreeに存在しない(ADR 0141)。
|
|
686
|
-
// carry-over側のrebind packetはcontent不変が要件なので、そちらは触らない——
|
|
687
|
-
// 継続する作業は自分のworktreeで走り続けており、baseを付け替える対象ではない。
|
|
688
|
-
base_sha: successorBaseSha ?? plan.base_sha,
|
|
679
|
+
base_sha: plan.base_sha,
|
|
689
680
|
nodes: structuredClone(plan.nodes),
|
|
690
681
|
precedence: structuredClone(plan.precedence),
|
|
691
682
|
conflicts: mergedConflicts,
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 実行時に観測した競合を、実際の変換で解消するproduct入口(請求項8)。
|
|
3
|
+
*
|
|
4
|
+
* これが無い間、変換の中身は動くのに実運転からそこへ行く道が無かった。実運転側が使う
|
|
5
|
+
* `routeConflictTreatment`は「事前宣言済みtreatmentがpathを覆う時だけseam_transform」なので、
|
|
6
|
+
* **予期しなかった競合は変換にかからず直列へ退化していた**。
|
|
7
|
+
*
|
|
8
|
+
* 装置の境界にAIを含める(AGENTS.md)。したがってここでLatticeが供給するのは、AIが自分では
|
|
9
|
+
* 作れないもの——構造観測、隔離実行、五条件の検証、変換の確定と記録——だけである。
|
|
10
|
+
* 「どのTODOが係争fileの中のどのsymbolを触るか」「新しい面をどう名付けるか」はAIが既に
|
|
11
|
+
* 知っているので、宣言として受け取る。推定しない。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
|
|
18
|
+
import { digestArtifact } from './artifact-contracts.mjs';
|
|
19
|
+
import { commitSeamTransform } from './seam-commit.mjs';
|
|
20
|
+
import { applySeamConflict } from './seam-apply.mjs';
|
|
21
|
+
import { resolveRuntimeSeamTreatment } from './runtime-seam-treatment.mjs';
|
|
22
|
+
import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
|
|
23
|
+
import { todoSelfDigest } from './todo-contracts.mjs';
|
|
24
|
+
|
|
25
|
+
export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
|
|
26
|
+
export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v1';
|
|
27
|
+
|
|
28
|
+
/** 合成するtodo planのkey。実行時planとは別空間なので固定でよい。 */
|
|
29
|
+
const SYNTHETIC_PLAN_KEY = 'runtime';
|
|
30
|
+
const HEX_DIGEST = /^[0-9a-f]{64}$/u;
|
|
31
|
+
const REPO_PATH = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[\w./-]+$/u;
|
|
32
|
+
const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
|
|
33
|
+
|
|
34
|
+
const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
35
|
+
|
|
36
|
+
/** 資源idはruntime front-endと同じ合成形にする(`own-<kind>-<sha16>`)。 */
|
|
37
|
+
const sha16 = (value) => createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
|
|
38
|
+
|
|
39
|
+
function plainObject(value) {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function exactRecord(value, keys) {
|
|
44
|
+
if (!plainObject(value)) return false;
|
|
45
|
+
const own = Object.keys(value);
|
|
46
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* AIが出す宣言。ここに書くのは「係争fileの中で各TODOが触るsymbol」「新しい面の名前」
|
|
51
|
+
* 「後継planへ渡すtask migrationのdigest」だけで、どれもAIが既に持っている情報である。
|
|
52
|
+
*/
|
|
53
|
+
export function validateRuntimeSeamRequest(value) {
|
|
54
|
+
if (!exactRecord(value, [
|
|
55
|
+
'schema', 'run_id', 'finding_digest', 'concern_symbols', 'path_names',
|
|
56
|
+
'task_migration_digest', 'request_digest',
|
|
57
|
+
])) return false;
|
|
58
|
+
if (value.schema !== RUNTIME_SEAM_REQUEST_SCHEMA) return false;
|
|
59
|
+
if (!IDENTIFIER.test(value.run_id ?? '')) return false;
|
|
60
|
+
if (!HEX_DIGEST.test(value.finding_digest ?? '')) return false;
|
|
61
|
+
if (!HEX_DIGEST.test(value.task_migration_digest ?? '')) return false;
|
|
62
|
+
if (!plainObject(value.concern_symbols) || !plainObject(value.path_names)) return false;
|
|
63
|
+
|
|
64
|
+
const todoIds = Object.keys(value.concern_symbols);
|
|
65
|
+
if (todoIds.length < 2 || todoIds.length > 64) return false;
|
|
66
|
+
for (const todoId of todoIds) {
|
|
67
|
+
if (!IDENTIFIER.test(todoId)) return false;
|
|
68
|
+
const symbols = value.concern_symbols[todoId];
|
|
69
|
+
if (!Array.isArray(symbols) || symbols.length === 0 || symbols.length > 256) return false;
|
|
70
|
+
if (!symbols.every((symbol) => IDENTIFIER.test(symbol))) return false;
|
|
71
|
+
if (!REPO_PATH.test(value.path_names[todoId] ?? '')) return false;
|
|
72
|
+
}
|
|
73
|
+
// sharedはresidualから切り出す共有面。TODOに属さないので別枠で必ず要る。
|
|
74
|
+
if (!REPO_PATH.test(value.path_names.shared ?? '')) return false;
|
|
75
|
+
if (!exactRecord(value.path_names, [...todoIds, 'shared'])) return false;
|
|
76
|
+
if (!HEX_DIGEST.test(value.request_digest ?? '')) return false;
|
|
77
|
+
return value.request_digest === todoSelfDigest(value, 'request_digest');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 実行時witnessへconcern anchorを足してtodo witness setにする。
|
|
82
|
+
*
|
|
83
|
+
* 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
|
|
84
|
+
* 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
|
|
85
|
+
* 契約に書けるものではないからである。
|
|
86
|
+
*/
|
|
87
|
+
export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath, executors }) {
|
|
88
|
+
const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
|
|
89
|
+
const manual = {};
|
|
90
|
+
for (const todoId of todoIds) {
|
|
91
|
+
const witness = request.manual_witness?.[todoId];
|
|
92
|
+
if (!plainObject(witness)) return { witnessSet: null, reasons: [`witness_absent:${todoId}`] };
|
|
93
|
+
manual[todoId] = {
|
|
94
|
+
...structuredClone(witness),
|
|
95
|
+
concern_anchors: [{
|
|
96
|
+
within: { kind: 'path', target: contestedPath },
|
|
97
|
+
symbols: [...declaration.concern_symbols[todoId]].sort(compareText),
|
|
98
|
+
}],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const witnessSet = {
|
|
102
|
+
schema: 'lattice.todo_witness_set.v3',
|
|
103
|
+
project_id: SYNTHETIC_PLAN_KEY,
|
|
104
|
+
plan_key: SYNTHETIC_PLAN_KEY,
|
|
105
|
+
capacity: { executors },
|
|
106
|
+
sensor_query_set: structuredClone(request.sensor_query_set),
|
|
107
|
+
manual_witness: manual,
|
|
108
|
+
witness_set_digest: '',
|
|
109
|
+
};
|
|
110
|
+
witnessSet.witness_set_digest = todoSelfDigest(witnessSet, 'witness_set_digest');
|
|
111
|
+
return { witnessSet, reasons: [] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function syntheticTodoPlan(todoIds) {
|
|
115
|
+
return {
|
|
116
|
+
schema: 'lattice.todo_plan.v2',
|
|
117
|
+
project_id: SYNTHETIC_PLAN_KEY,
|
|
118
|
+
plan_key: SYNTHETIC_PLAN_KEY,
|
|
119
|
+
plan_version: 'v1',
|
|
120
|
+
topology_digest: digestArtifact({ tasks: [...todoIds].sort(compareText) }),
|
|
121
|
+
tasks: [...todoIds].sort(compareText).map((taskId) => ({ task_id: taskId })),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 記録済みfindingと宣言から、変換を導出・適用・検証・確定して処置を返す。
|
|
127
|
+
*
|
|
128
|
+
* 五条件(ADR 0138)を1つでも欠いたら意図的直列を返す。確定できなければ採用しない(ADR 0141)。
|
|
129
|
+
* 「変換した」と言いながら再開できない状態を作らない。
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveRuntimeSeam({
|
|
132
|
+
repoRoot, runDir, findingRecord, bundle, declaration, latticeBin, compiledAt,
|
|
133
|
+
} = {}) {
|
|
134
|
+
const finding = findingRecord.finding;
|
|
135
|
+
const request = bundle.request;
|
|
136
|
+
const baseSha = request.repo.base_sha;
|
|
137
|
+
const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
|
|
138
|
+
|
|
139
|
+
if (finding.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
|
|
140
|
+
return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null };
|
|
141
|
+
}
|
|
142
|
+
const findingTodoIds = [...finding.todo_ids].sort(compareText);
|
|
143
|
+
if (findingTodoIds.join('\0') !== todoIds.join('\0')) {
|
|
144
|
+
return {
|
|
145
|
+
lane: 'intentional_serial',
|
|
146
|
+
reasons: ['declared_todos_differ_from_finding'],
|
|
147
|
+
split: null,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const built = buildRuntimeSeamWitnessSet({
|
|
152
|
+
request, declaration, contestedPath: finding.path,
|
|
153
|
+
executors: request.capacity.executors,
|
|
154
|
+
});
|
|
155
|
+
if (built.witnessSet === null) {
|
|
156
|
+
return { lane: 'intentional_serial', reasons: built.reasons, split: null };
|
|
157
|
+
}
|
|
158
|
+
const witnessSet = built.witnessSet;
|
|
159
|
+
const plan = syntheticTodoPlan(todoIds);
|
|
160
|
+
|
|
161
|
+
// 観測したaffected testsだけを検証に使う。宣言から発明しない。
|
|
162
|
+
const affectedTests = [...new Set(todoIds
|
|
163
|
+
.flatMap((todoId) => request.manual_witness[todoId].affected_tests))].sort(compareText);
|
|
164
|
+
|
|
165
|
+
const baseArtifact = compileTodoIndependence({
|
|
166
|
+
witnessSet, plan, baseSha, compiledAt,
|
|
167
|
+
sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const pathNames = { ...declaration.path_names };
|
|
171
|
+
return resolveRuntimeSeamTreatment({
|
|
172
|
+
finding,
|
|
173
|
+
witnessSet,
|
|
174
|
+
pathNames,
|
|
175
|
+
baseSha,
|
|
176
|
+
manifestDigest: baseArtifact.result_digest,
|
|
177
|
+
affectedTests,
|
|
178
|
+
taskMigrationDigest: declaration.task_migration_digest,
|
|
179
|
+
commitTransform: async ({ files, candidateId }) => commitSeamTransform({
|
|
180
|
+
repoRoot, baseSha, files, candidateId,
|
|
181
|
+
}),
|
|
182
|
+
applyConflict: async ({ conflict }) => {
|
|
183
|
+
const applied = await applySeamConflict({
|
|
184
|
+
repoRoot,
|
|
185
|
+
planKey: SYNTHETIC_PLAN_KEY,
|
|
186
|
+
conflict,
|
|
187
|
+
witnessSet,
|
|
188
|
+
latticeBin,
|
|
189
|
+
sharedPathFor: () => declaration.path_names.shared,
|
|
190
|
+
executors: request.capacity.executors,
|
|
191
|
+
pathNames,
|
|
192
|
+
compileIndependence: {
|
|
193
|
+
baseArtifact,
|
|
194
|
+
inWorktree: async ({ worktreePath, witnessSet: postWitness }) => compileTodoIndependence({
|
|
195
|
+
witnessSet: postWitness,
|
|
196
|
+
plan,
|
|
197
|
+
baseSha,
|
|
198
|
+
compiledAt,
|
|
199
|
+
sensorEvidence: await collectWitnessSensorEvidence({
|
|
200
|
+
cwd: worktreePath, witnessSet: postWitness,
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
return { ...applied, candidate: applied.candidate ?? null };
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 決着をartifactにする。branchを動かすのは操作するAIなので、行き先を明示して返す。 */
|
|
211
|
+
export function buildRuntimeSeamResolution({ runId, findingDigest, resolved }) {
|
|
212
|
+
const resolution = {
|
|
213
|
+
schema: RUNTIME_SEAM_RESOLUTION_SCHEMA,
|
|
214
|
+
run_id: runId,
|
|
215
|
+
finding_digest: findingDigest,
|
|
216
|
+
lane: resolved.lane,
|
|
217
|
+
reasons: [...resolved.reasons].sort(compareText),
|
|
218
|
+
split: resolved.split ?? null,
|
|
219
|
+
successor_base_sha: resolved.successor_base_sha ?? null,
|
|
220
|
+
successor_base_ref: resolved.successor_base_ref ?? null,
|
|
221
|
+
resolution_digest: '',
|
|
222
|
+
};
|
|
223
|
+
resolution.resolution_digest = todoSelfDigest(resolution, 'resolution_digest');
|
|
224
|
+
return resolution;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* seam_split再計画で、後継baseが本当に変換を含むかを検査する(ADR 0141)。
|
|
229
|
+
*
|
|
230
|
+
* これが無い間、`mode: 'seam_split'`の再計画requestは**変換を含まないbaseを指していても通った**。
|
|
231
|
+
* splitは新しい面の所有を宣言するのに、compileされる後継treeにそのfileが無い——rb工程で
|
|
232
|
+
* 直したのと同じ欠陥が、管理runtimeの層に残っていた。
|
|
233
|
+
*
|
|
234
|
+
* 検査するのは3つ。どれもLatticeが既に持っているartifactだけで判定でき、推定を含まない。
|
|
235
|
+
*
|
|
236
|
+
* 1. baseが前進し、かつ旧baseの子孫であること。変換が着地していなければ前進しない。
|
|
237
|
+
* 2. splitが「消える」と述べた競合辺が、後継planに実際に無いこと。後継treeに変換が
|
|
238
|
+
* 載っていなければ両TODOは同じfileを書き続けるので、この辺は消えない。
|
|
239
|
+
* 3. splitが新たに所有すると述べた資源が、後継requestで**creationとして宣言されていない**こと。
|
|
240
|
+
* seam splitは既存codeを新しい面へ移す操作であり、変換が既に作っている。これから作る、
|
|
241
|
+
* と宣言されているなら、指しているbaseは変換前である。
|
|
242
|
+
*/
|
|
243
|
+
export function verifySeamSplitSuccessor({
|
|
244
|
+
split, predecessorBaseSha, successorBaseSha, successorIsDescendant,
|
|
245
|
+
successorConflicts = [], successorWitness = {},
|
|
246
|
+
} = {}) {
|
|
247
|
+
const reasons = [];
|
|
248
|
+
if (successorBaseSha === predecessorBaseSha) reasons.push('successor_base_not_advanced');
|
|
249
|
+
else if (successorIsDescendant !== true) reasons.push('successor_base_not_descendant');
|
|
250
|
+
|
|
251
|
+
const removedEdges = split?.edge_diff?.removed ?? [];
|
|
252
|
+
const remaining = new Set(successorConflicts
|
|
253
|
+
.map(({ todo_ids: ids }) => [...ids].sort(compareText).join('\0')));
|
|
254
|
+
for (const edge of removedEdges) {
|
|
255
|
+
const key = [edge.from_todo_id, edge.to_todo_id].sort(compareText).join('\0');
|
|
256
|
+
if (remaining.has(key)) reasons.push(`declared_removed_conflict_persists:${key.replace('\0', ',')}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
for (const added of split?.ownership_diff?.added ?? []) {
|
|
260
|
+
const owns = successorWitness[added.owner_todo_id]?.owns ?? [];
|
|
261
|
+
for (const own of owns) {
|
|
262
|
+
const resourceId = `own-${own.kind}-${sha16(own.target)}`;
|
|
263
|
+
if (resourceId === added.resource_id && own.creates === true) {
|
|
264
|
+
reasons.push(`declared_owned_surface_is_creation:${added.owner_todo_id}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { ok: reasons.length === 0, reasons: [...new Set(reasons)].sort(compareText) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** `<runDir>/findings/<digest>.json`を読む。存在しない/別epochのfindingは受けない。 */
|
|
272
|
+
export async function readRuntimeFindingRecord({ runDir, findingDigest, planEpoch }) {
|
|
273
|
+
const filePath = path.join(runDir, 'findings', `${findingDigest}.json`);
|
|
274
|
+
let record;
|
|
275
|
+
try {
|
|
276
|
+
record = JSON.parse(await readFile(filePath, 'utf8'));
|
|
277
|
+
} catch {
|
|
278
|
+
return { record: null, reason: 'finding_not_recorded' };
|
|
279
|
+
}
|
|
280
|
+
if (record?.finding_digest !== findingDigest) return { record: null, reason: 'finding_digest_mismatch' };
|
|
281
|
+
if (record.plan_epoch !== planEpoch) return { record: null, reason: 'finding_from_other_epoch' };
|
|
282
|
+
return { record, reason: null };
|
|
283
|
+
}
|
package/src/seam-apply.mjs
CHANGED
|
Binary file
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
export const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export const GIT_SHA1 = /^[0-9a-f]{40}$/u;
|
|
8
|
+
|
|
9
|
+
export const SEAM_REF_PREFIX = 'refs/lattice/seam';
|
|
10
|
+
|
|
11
|
+
export async function git(args, cwd) {
|
|
12
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
13
|
+
cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
14
|
+
});
|
|
15
|
+
return stdout;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function safeRelative(target) {
|
|
19
|
+
return typeof target === 'string' && target.length > 0 && !target.includes('\0')
|
|
20
|
+
&& !path.posix.isAbsolute(target) && target === path.posix.normalize(target)
|
|
21
|
+
&& !target.split('/').includes('..');
|
|
22
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { GIT_SHA1, execFileAsync, git, safeRelative } from './seam-commit-shared.mjs';
|
|
5
|
+
import { seamRefFor } from './seam-ref.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 採用された変換をcommitへ確定し、そのshaを返す。
|
|
9
|
+
*
|
|
10
|
+
* 本repositoryの作業ツリーは触らない。使い捨てworktreeをbaseへ張り、変換後のfileを書き、
|
|
11
|
+
* detached HEADでcommitし、refへ繋いでからworktreeを畳む。commit objectはobject DBを共有する
|
|
12
|
+
* ので、worktreeを消してもshaは生き残る——refはそれをGCから守るためである。
|
|
13
|
+
*
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {string} options.repoRoot 本repository
|
|
16
|
+
* @param {string} options.baseSha 変換前のbase
|
|
17
|
+
* @param {object} options.files pathごとの変換後text
|
|
18
|
+
* @param {string} options.candidateId ref名に使う識別子
|
|
19
|
+
* @param {string} options.message commit message
|
|
20
|
+
* @returns {Promise<{commitSha: string, ref: string}>}
|
|
21
|
+
*/
|
|
22
|
+
export async function commitSeamTransform({
|
|
23
|
+
repoRoot, baseSha, files, candidateId, message,
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRootが不正');
|
|
26
|
+
if (!GIT_SHA1.test(baseSha ?? '')) throw new TypeError('baseShaが不正');
|
|
27
|
+
if (files === null || typeof files !== 'object' || Array.isArray(files)
|
|
28
|
+
|| Object.keys(files).length === 0) throw new TypeError('filesが不正');
|
|
29
|
+
const targets = Object.keys(files);
|
|
30
|
+
if (!targets.every(safeRelative)) throw new TypeError('変換後pathがrepo相対規律を満たさない');
|
|
31
|
+
if (typeof candidateId !== 'string' || !/^[0-9A-Za-z][\w.-]{0,127}$/u.test(candidateId)) {
|
|
32
|
+
throw new TypeError('candidateIdが不正');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-seam-commit-'));
|
|
36
|
+
const worktreePath = path.join(worktreeRoot, 'tree');
|
|
37
|
+
let commitSha;
|
|
38
|
+
try {
|
|
39
|
+
await git(['worktree', 'add', '--detach', '--quiet', worktreePath, baseSha], repoRoot);
|
|
40
|
+
for (const [target, text] of Object.entries(files)) {
|
|
41
|
+
const absolute = path.join(worktreePath, target);
|
|
42
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
43
|
+
await writeFile(absolute, text);
|
|
44
|
+
}
|
|
45
|
+
await git(['add', '--', ...targets], worktreePath);
|
|
46
|
+
// 変換で1 byteも変わらなかったなら、確定すべき成果が無い。空commitで
|
|
47
|
+
// 「進んだ」ように見せない。
|
|
48
|
+
const staged = await git(['diff', '--cached', '--name-only'], worktreePath);
|
|
49
|
+
if (staged.trim() === '') throw new TypeError('変換後の差分が無い');
|
|
50
|
+
await git([
|
|
51
|
+
'-c', 'user.email=lattice@localhost', '-c', 'user.name=lattice',
|
|
52
|
+
'commit', '--quiet', '-m', message ?? `seam transform ${candidateId}`,
|
|
53
|
+
], worktreePath);
|
|
54
|
+
commitSha = (await git(['rev-parse', 'HEAD'], worktreePath)).trim();
|
|
55
|
+
if (!GIT_SHA1.test(commitSha)) throw new TypeError('commit shaが不正');
|
|
56
|
+
} finally {
|
|
57
|
+
await git(['worktree', 'remove', '--force', worktreePath], repoRoot).catch(() => {});
|
|
58
|
+
await rm(worktreeRoot, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// worktreeを畳んだ後にrefを張る。object DBは共有なのでshaは生きているが、
|
|
62
|
+
// refが無いとGCの対象になる。
|
|
63
|
+
const ref = seamRefFor(candidateId);
|
|
64
|
+
// 同じcandidateへ2回目の変換が来た時、黙って上書きすると1回目の証跡が消える。
|
|
65
|
+
// 連鎖は「前の変換を含むbaseの上で次を確定する」形でしか正しくならないので、
|
|
66
|
+
// 既存refが今回のbaseの祖先を指していなければ拒む(ADR 0142 / ADR 0141 OQ2)。
|
|
67
|
+
const existing = await git(['for-each-ref', '--format=%(objectname)', ref], repoRoot)
|
|
68
|
+
.then((stdout) => stdout.trim())
|
|
69
|
+
.catch(() => '');
|
|
70
|
+
if (GIT_SHA1.test(existing) && existing !== commitSha) {
|
|
71
|
+
const chained = await execFileAsync('git', ['merge-base', '--is-ancestor', existing, baseSha], {
|
|
72
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
73
|
+
}).then(() => true).catch(() => false);
|
|
74
|
+
if (!chained) {
|
|
75
|
+
throw new Error(`seam ref ${ref} は既に ${existing} を指しており、今回のbaseはその子孫でない`
|
|
76
|
+
+ '。前の変換を含むbaseの上で確定するか、別のcandidate idを使う');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await git(['update-ref', ref, commitSha], repoRoot);
|
|
80
|
+
return { commitSha, ref };
|
|
81
|
+
}
|
package/src/seam-commit.mjs
CHANGED
|
Binary file
|
package/src/seam-ref.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SEAM_REF_PREFIX, git } from './seam-commit-shared.mjs';
|
|
2
|
+
|
|
3
|
+
/** 変換の成果を指すref。branch名前空間へ置かないので、通常のbranch一覧には現れない。 */
|
|
4
|
+
export function seamRefFor(candidateId) {
|
|
5
|
+
return `${SEAM_REF_PREFIX}/${candidateId}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 確定済みseam refを列挙する(ADR 0142 / ADR 0141 OQ1)。
|
|
10
|
+
*
|
|
11
|
+
* **自動では消さない。** このrefが指すのは「五条件を通って受理された変換の実体」であり、
|
|
12
|
+
* どの版がどの競合をどう解いたかを後から辿れる唯一の資源である。runが閉じたら消す設計も
|
|
13
|
+
* 検討したが、それは証跡を寿命付きにするということで、記録を所有するという製品の役目と
|
|
14
|
+
* 衝突する。消すかどうかは所有者の裁定に委ね、道具は「何が在るか」を見せる側だけを持つ。
|
|
15
|
+
*
|
|
16
|
+
* @returns {Promise<Array<{ref: string, candidate_id: string, commit_sha: string}>>}
|
|
17
|
+
*/
|
|
18
|
+
export async function listSeamRefs({ repoRoot } = {}) {
|
|
19
|
+
let stdout;
|
|
20
|
+
try {
|
|
21
|
+
stdout = await git(['for-each-ref', '--format=%(refname) %(objectname)', SEAM_REF_PREFIX], repoRoot);
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return stdout.split('\n')
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter((line) => line.length > 0)
|
|
28
|
+
.map((line) => {
|
|
29
|
+
const [ref, commitSha] = line.split(' ');
|
|
30
|
+
return { ref, candidate_id: ref.slice(`${SEAM_REF_PREFIX}/`.length), commit_sha: commitSha };
|
|
31
|
+
})
|
|
32
|
+
.sort((left, right) => (left.ref < right.ref ? -1 : left.ref > right.ref ? 1 : 0));
|
|
33
|
+
}
|
package/src/seam-rewrite.mjs
CHANGED
|
@@ -85,6 +85,13 @@ function exportedBlock(raw) {
|
|
|
85
85
|
return parts.join('\n');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** 原pathでexport宣言だったか。移動先でexportを足したかではなく、元の姿を見る。 */
|
|
89
|
+
function wasExported(raw) {
|
|
90
|
+
const declaration = raw.split('\n')
|
|
91
|
+
.find((line) => !COMMENT_LINE.test(line) && line.trim() !== '');
|
|
92
|
+
return declaration !== undefined && /^\s*export\s/u.test(declaration);
|
|
93
|
+
}
|
|
94
|
+
|
|
88
95
|
function relativeSpecifier(fromPath, toPath) {
|
|
89
96
|
const fromDir = fromPath.slice(0, fromPath.lastIndexOf('/') + 1);
|
|
90
97
|
return toPath.startsWith(fromDir) ? `./${toPath.slice(fromDir.length)}` : `./${toPath}`;
|
|
@@ -138,10 +145,17 @@ export function planSeamRewrite({ sourceText, candidate, symbolExtents } = {}) {
|
|
|
138
145
|
|
|
139
146
|
const bodyByPath = new Map();
|
|
140
147
|
const removal = new Set();
|
|
148
|
+
// 原pathでexportされていたsymbolは、移した先から残余面が再exportする。
|
|
149
|
+
// しないと原pathをimportしている全fileが壊れ、外部挙動同等性が原理的に満たせない。
|
|
150
|
+
const reExportByPath = new Map();
|
|
141
151
|
for (const block of blocks) {
|
|
142
152
|
const raw = lines.slice(block.start - 1, block.end).join('\n');
|
|
143
153
|
if (!bodyByPath.has(block.path)) bodyByPath.set(block.path, []);
|
|
144
154
|
bodyByPath.get(block.path).push(exportedBlock(raw));
|
|
155
|
+
if (wasExported(raw)) {
|
|
156
|
+
if (!reExportByPath.has(block.path)) reExportByPath.set(block.path, []);
|
|
157
|
+
reExportByPath.get(block.path).push(block.symbol);
|
|
158
|
+
}
|
|
145
159
|
for (let line = block.start; line <= block.end; line += 1) removal.add(line);
|
|
146
160
|
}
|
|
147
161
|
|
|
@@ -175,7 +189,11 @@ export function planSeamRewrite({ sourceText, candidate, symbolExtents } = {}) {
|
|
|
175
189
|
const residualBody = keptBody.join('\n').replace(/\n{3,}/gu, '\n\n').replace(/\n+$/u, '');
|
|
176
190
|
const residualCross = importsFor(residual.path, residualBody)
|
|
177
191
|
.filter((statement) => !keptHeader.join('\n').includes(statement));
|
|
178
|
-
|
|
192
|
+
const reExports = [...reExportByPath.entries()]
|
|
193
|
+
.sort(([left], [right]) => compareText(left, right))
|
|
194
|
+
.map(([targetPath, names]) => `export { ${[...names].sort(compareText).join(', ')} }`
|
|
195
|
+
+ ` from '${relativeSpecifier(residual.path, targetPath)}';`);
|
|
196
|
+
files[residual.path] = `${[...keptHeader, ...residualCross, ...reExports].join('\n')}\n${residualBody}\n`
|
|
179
197
|
.replace(/^\n+/u, '');
|
|
180
198
|
|
|
181
199
|
return { files, reasons: [] };
|
package/src/todo-cli.mjs
CHANGED
|
@@ -276,7 +276,7 @@ async function readEvidenceInput(repoRoot, inputRef) {
|
|
|
276
276
|
});
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
|
|
279
|
+
async function readJsonInput(repoRoot, inputRef, { validate, invalidCode, expected = null }) {
|
|
280
280
|
if (!isTodoRef(inputRef)) {
|
|
281
281
|
throw new TodoStoreError('INPUT_UNREADABLE', 'input_path_outside_repo', undefined, { input_ref: inputRef });
|
|
282
282
|
}
|
|
@@ -321,7 +321,10 @@ async function readJsonInput(repoRoot, inputRef, { validate, invalidCode }) {
|
|
|
321
321
|
throw new TodoStoreError('INVALID_JSON', 'json_parse_failed');
|
|
322
322
|
}
|
|
323
323
|
if (!validate(descriptor)) {
|
|
324
|
-
|
|
324
|
+
// 「schema_invalid」だけを返すと、呼び出したAIは何をどう直せばよいか分からない。
|
|
325
|
+
// 期待する形を渡されている入口は、それをそのまま返す(ADR 0130の案内規律)。
|
|
326
|
+
throw new TodoStoreError(invalidCode, 'schema_invalid', undefined,
|
|
327
|
+
expected === null ? undefined : { expected });
|
|
325
328
|
}
|
|
326
329
|
return descriptor;
|
|
327
330
|
}
|
|
@@ -1006,6 +1009,11 @@ async function readSeamPathNames(repoRoot, inputRef) {
|
|
|
1006
1009
|
&& Object.entries(candidate.names)
|
|
1007
1010
|
.every(([key, target]) => isTodoIdentifier(key) && isTodoRef(target)),
|
|
1008
1011
|
invalidCode: 'SEAM_PATH_NAMES_INVALID',
|
|
1012
|
+
expected: {
|
|
1013
|
+
schema: 'lattice.seam_path_names.v1',
|
|
1014
|
+
shape: '{ "schema": "lattice.seam_path_names.v1", "names": { "<task_id>": "<repo相対path>" } }',
|
|
1015
|
+
note: '所有面はtask_idごとに、共有面は"shared"というkeyで名前を与える',
|
|
1016
|
+
},
|
|
1009
1017
|
});
|
|
1010
1018
|
return value.names;
|
|
1011
1019
|
}
|