@quolu/lattice 0.52.2 → 0.52.4
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 +1 -1
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +699 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3143 -3141
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4360 -4322
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
package/src/bounded-seam.mjs
CHANGED
|
@@ -1,230 +1,230 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
exactRecord, isTodoDigest, isTodoIdentifier, isTodoRef, todoSelfDigest,
|
|
8
|
-
} from './todo-contracts.mjs';
|
|
9
|
-
|
|
10
|
-
const GIT_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
|
|
11
|
-
|
|
12
|
-
function sortedUniqueRefs(value, { nonempty = false } = {}) {
|
|
13
|
-
return Array.isArray(value) && (!nonempty || value.length > 0) && value.every(isTodoRef)
|
|
14
|
-
&& value.every((entry, index) => index === 0 || value[index - 1] < entry);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* v2の三面surface(ADR 0137 Decision 1)。所有だけがtaskへ紐づく。
|
|
19
|
-
*
|
|
20
|
-
* 残余はsymbolを列挙しない。移らなかったものすべてという補集合であり、列挙を要求すると
|
|
21
|
-
* 原fileの全symbol目録が契約の一部になる。
|
|
22
|
-
*/
|
|
23
|
-
function seamSurface(value, taskIds) {
|
|
24
|
-
if (!exactRecord(value, ['role', 'path', 'owner_task_ids', 'symbols'])
|
|
25
|
-
|| !['task_owned', 'shared', 'residual'].includes(value.role)
|
|
26
|
-
|| !isTodoRef(value.path)
|
|
27
|
-
|| !Array.isArray(value.owner_task_ids)
|
|
28
|
-
|| !sortedUniqueSymbols(value.symbols)) return false;
|
|
29
|
-
if (value.role === 'task_owned') {
|
|
30
|
-
return value.owner_task_ids.length === 1 && taskIds.has(value.owner_task_ids[0])
|
|
31
|
-
&& value.symbols.length > 0;
|
|
32
|
-
}
|
|
33
|
-
if (value.owner_task_ids.length !== 0) return false;
|
|
34
|
-
return value.role === 'shared' ? value.symbols.length > 0 : value.symbols.length === 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function sortedUniqueSymbols(value) {
|
|
38
|
-
return Array.isArray(value) && value.every(isTodoIdentifier)
|
|
39
|
-
&& value.every((entry, index) => index === 0 || value[index - 1] < entry);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function validateBoundedSeamCandidateV2(value) {
|
|
43
|
-
if (!exactRecord(value, [
|
|
44
|
-
'schema', 'candidate_id', 'base_sha', 'manifest_digest', 'finding_digest',
|
|
45
|
-
'source_path', 'todo_refs', 'surfaces', 'allowed_paths', 'required_paths',
|
|
46
|
-
'verification_policy', 'candidate_digest',
|
|
47
|
-
])
|
|
48
|
-
|| !isTodoIdentifier(value.candidate_id) || !GIT_OID.test(value.base_sha)
|
|
49
|
-
|| !isTodoDigest(value.manifest_digest) || !isTodoDigest(value.finding_digest)
|
|
50
|
-
|| !isTodoRef(value.source_path)
|
|
51
|
-
|| !Array.isArray(value.todo_refs) || value.todo_refs.length < 2
|
|
52
|
-
|| !value.todo_refs.every((entry) => exactRecord(entry, ['plan_key', 'task_id'])
|
|
53
|
-
&& isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.task_id))
|
|
54
|
-
|| !value.todo_refs.every((entry, index) => index === 0
|
|
55
|
-
|| `${value.todo_refs[index - 1].plan_key}\0${value.todo_refs[index - 1].task_id}`
|
|
56
|
-
< `${entry.plan_key}\0${entry.task_id}`)) return false;
|
|
57
|
-
|
|
58
|
-
const taskIds = new Set(value.todo_refs.map(({ task_id: taskId }) => taskId));
|
|
59
|
-
if (!Array.isArray(value.surfaces)
|
|
60
|
-
|| !value.surfaces.every((surface) => seamSurface(surface, taskIds))
|
|
61
|
-
|| !value.surfaces.every((surface, index) => index === 0
|
|
62
|
-
|| value.surfaces[index - 1].path < surface.path)) return false;
|
|
63
|
-
|
|
64
|
-
// 所有面は全taskへ1つずつ。残余は原path上に必ず1つ。共有面は0または1(粒度は未裁定)。
|
|
65
|
-
const owned = value.surfaces.filter(({ role }) => role === 'task_owned');
|
|
66
|
-
const shared = value.surfaces.filter(({ role }) => role === 'shared');
|
|
67
|
-
const residual = value.surfaces.filter(({ role }) => role === 'residual');
|
|
68
|
-
const ownerIds = new Set(owned.flatMap(({ owner_task_ids: ids }) => ids));
|
|
69
|
-
if (owned.length !== taskIds.size || ownerIds.size !== taskIds.size
|
|
70
|
-
|| shared.length > 1 || residual.length !== 1
|
|
71
|
-
|| residual[0].path !== value.source_path) return false;
|
|
72
|
-
|
|
73
|
-
// symbolは1面にしか属せない。二重所有は移動先が決まらない。
|
|
74
|
-
const symbols = value.surfaces.flatMap(({ symbols: list }) => list);
|
|
75
|
-
if (new Set(symbols).size !== symbols.length) return false;
|
|
76
|
-
|
|
77
|
-
if (!sortedUniqueRefs(value.allowed_paths, { nonempty: true })
|
|
78
|
-
|| !sortedUniqueRefs(value.required_paths, { nonempty: true })
|
|
79
|
-
|| !value.required_paths.every((entry) => value.allowed_paths.includes(entry))
|
|
80
|
-
|| !value.surfaces.every(({ path }) => value.allowed_paths.includes(path))
|
|
81
|
-
|| !value.required_paths.includes(value.source_path)
|
|
82
|
-
|| !owned.every(({ path }) => value.required_paths.includes(path))) return false;
|
|
83
|
-
|
|
84
|
-
// ADR 0138の五条件。1つでもfalseなら、その候補は採用条件を緩めた別物である。
|
|
85
|
-
return exactRecord(value.verification_policy, [
|
|
86
|
-
'focused_test_refs', 'require_behavior_equivalence', 'require_fresh_sensor',
|
|
87
|
-
'require_overlap_reduction', 'require_no_new_conflict_pairs',
|
|
88
|
-
'require_parallelism_improvement',
|
|
89
|
-
])
|
|
90
|
-
&& sortedUniqueRefs(value.verification_policy.focused_test_refs, { nonempty: true })
|
|
91
|
-
&& value.verification_policy.require_behavior_equivalence === true
|
|
92
|
-
&& value.verification_policy.require_fresh_sensor === true
|
|
93
|
-
&& value.verification_policy.require_overlap_reduction === true
|
|
94
|
-
&& value.verification_policy.require_no_new_conflict_pairs === true
|
|
95
|
-
&& value.verification_policy.require_parallelism_improvement === true
|
|
96
|
-
&& isTodoDigest(value.candidate_digest)
|
|
97
|
-
&& value.candidate_digest === todoSelfDigest(value, 'candidate_digest');
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function validateBoundedSeamCandidate(value) {
|
|
101
|
-
try {
|
|
102
|
-
if (value?.schema === 'lattice.bounded_seam_candidate.v2') {
|
|
103
|
-
return validateBoundedSeamCandidateV2(value);
|
|
104
|
-
}
|
|
105
|
-
return exactRecord(value, [
|
|
106
|
-
'schema', 'candidate_id', 'base_sha', 'manifest_digest', 'finding_digest',
|
|
107
|
-
'todo_refs', 'anchor', 'allowed_paths', 'required_paths', 'verification_policy',
|
|
108
|
-
'candidate_digest',
|
|
109
|
-
]) && value.schema === 'lattice.bounded_seam_candidate.v1'
|
|
110
|
-
&& isTodoIdentifier(value.candidate_id) && GIT_OID.test(value.base_sha)
|
|
111
|
-
&& isTodoDigest(value.manifest_digest) && isTodoDigest(value.finding_digest)
|
|
112
|
-
&& Array.isArray(value.todo_refs) && value.todo_refs.length >= 2
|
|
113
|
-
&& value.todo_refs.every((entry) => exactRecord(entry, ['plan_key', 'task_id'])
|
|
114
|
-
&& isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.task_id))
|
|
115
|
-
&& value.todo_refs.every((entry, index) => index === 0
|
|
116
|
-
|| `${value.todo_refs[index - 1].plan_key}\0${value.todo_refs[index - 1].task_id}`
|
|
117
|
-
< `${entry.plan_key}\0${entry.task_id}`)
|
|
118
|
-
&& exactRecord(value.anchor, ['path', 'symbol', 'start_line', 'end_line'])
|
|
119
|
-
&& isTodoRef(value.anchor.path) && isTodoIdentifier(value.anchor.symbol)
|
|
120
|
-
&& Number.isSafeInteger(value.anchor.start_line) && value.anchor.start_line >= 1
|
|
121
|
-
&& Number.isSafeInteger(value.anchor.end_line) && value.anchor.end_line >= value.anchor.start_line
|
|
122
|
-
&& sortedUniqueRefs(value.allowed_paths, { nonempty: true })
|
|
123
|
-
&& sortedUniqueRefs(value.required_paths, { nonempty: true })
|
|
124
|
-
&& value.required_paths.every((entry) => value.allowed_paths.includes(entry))
|
|
125
|
-
&& value.allowed_paths.includes(value.anchor.path)
|
|
126
|
-
&& exactRecord(value.verification_policy, [
|
|
127
|
-
'focused_test_ids', 'require_behavior_equivalence', 'require_fresh_sensor',
|
|
128
|
-
'require_overlap_reduction',
|
|
129
|
-
]) && Array.isArray(value.verification_policy.focused_test_ids)
|
|
130
|
-
&& value.verification_policy.focused_test_ids.length > 0
|
|
131
|
-
&& value.verification_policy.focused_test_ids.every(isTodoIdentifier)
|
|
132
|
-
&& value.verification_policy.focused_test_ids.every((entry, index) => index === 0
|
|
133
|
-
|| value.verification_policy.focused_test_ids[index - 1] < entry)
|
|
134
|
-
&& value.verification_policy.require_behavior_equivalence === true
|
|
135
|
-
&& value.verification_policy.require_fresh_sensor === true
|
|
136
|
-
&& value.verification_policy.require_overlap_reduction === true
|
|
137
|
-
&& isTodoDigest(value.candidate_digest)
|
|
138
|
-
&& value.candidate_digest === todoSelfDigest(value, 'candidate_digest');
|
|
139
|
-
} catch { return false; }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function run(command, args, cwd) {
|
|
143
|
-
return new Promise((resolve, reject) => {
|
|
144
|
-
const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
145
|
-
const stdout = []; const stderr = [];
|
|
146
|
-
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
147
|
-
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
148
|
-
child.once('error', reject);
|
|
149
|
-
child.once('close', (code, signal) => {
|
|
150
|
-
if (code === 0 && signal === null) resolve(Buffer.concat(stdout));
|
|
151
|
-
else reject(new Error(`${command} failed (${signal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`));
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function fingerprint(repoRoot) {
|
|
157
|
-
const [head, status, refs] = await Promise.all([
|
|
158
|
-
run('git', ['rev-parse', 'HEAD'], repoRoot),
|
|
159
|
-
run('git', ['status', '--porcelain=v1', '--untracked-files=all'], repoRoot),
|
|
160
|
-
run('git', ['for-each-ref', '--format=%(refname) %(objectname)'], repoRoot),
|
|
161
|
-
]);
|
|
162
|
-
return Buffer.concat([head, status, refs]).toString('hex');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
async function changedPaths(worktreePath) {
|
|
166
|
-
const bytes = await run('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], worktreePath);
|
|
167
|
-
const fields = bytes.toString('utf8').split('\0').filter(Boolean);
|
|
168
|
-
const paths = [];
|
|
169
|
-
for (let index = 0; index < fields.length; index += 1) {
|
|
170
|
-
const field = fields[index];
|
|
171
|
-
const status = field.slice(0, 2);
|
|
172
|
-
paths.push(field.slice(3));
|
|
173
|
-
if (status.includes('R') || status.includes('C')) paths.push(fields[++index]);
|
|
174
|
-
}
|
|
175
|
-
return [...new Set(paths)].sort();
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function outcome(candidate, decision, reason, changed, verification, cleanupOk) {
|
|
179
|
-
const result = { schema: 'lattice.bounded_seam_outcome.v1', candidate_id: candidate.candidate_id,
|
|
180
|
-
candidate_digest: candidate.candidate_digest, decision, reason, changed_paths: changed,
|
|
181
|
-
verification, cleanup_ok: cleanupOk, outcome_digest: '' };
|
|
182
|
-
result.outcome_digest = todoSelfDigest(result, 'outcome_digest');
|
|
183
|
-
return result;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export async function runBoundedSeamCandidate({ repoRoot, candidate, transform, verify }) {
|
|
187
|
-
if (!validateBoundedSeamCandidate(candidate)) throw new TypeError('bounded seam candidate invalid');
|
|
188
|
-
if (typeof repoRoot !== 'string' || typeof transform !== 'function' || typeof verify !== 'function') {
|
|
189
|
-
throw new TypeError('bounded seam runner options invalid');
|
|
190
|
-
}
|
|
191
|
-
const before = await fingerprint(repoRoot);
|
|
192
|
-
const head = (await run('git', ['rev-parse', 'HEAD'], repoRoot)).toString('utf8').trim();
|
|
193
|
-
if (head !== candidate.base_sha) return outcome(candidate, 'rejected', 'stale_base', [], null, true);
|
|
194
|
-
const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-bounded-seam-'));
|
|
195
|
-
const worktreePath = path.join(worktreeRoot, 'tree');
|
|
196
|
-
let changed = [];
|
|
197
|
-
let result;
|
|
198
|
-
let cleanupOk = false;
|
|
199
|
-
try {
|
|
200
|
-
await run('git', ['worktree', 'add', '--detach', worktreePath, candidate.base_sha], repoRoot);
|
|
201
|
-
try { await transform({ worktreePath, candidate: structuredClone(candidate) }); }
|
|
202
|
-
catch { result = outcome(candidate, 'rejected', 'transform_failed', [], null, false); }
|
|
203
|
-
if (result === undefined) changed = await changedPaths(worktreePath);
|
|
204
|
-
if (result === undefined && (changed.some((entry) => !candidate.allowed_paths.includes(entry))
|
|
205
|
-
|| candidate.required_paths.some((entry) => !changed.includes(entry)))) {
|
|
206
|
-
result = outcome(candidate, 'rejected', 'scope_drift', changed, null, false);
|
|
207
|
-
} else if (result === undefined) {
|
|
208
|
-
let verification;
|
|
209
|
-
try { verification = await verify({ worktreePath, candidate: structuredClone(candidate), changed_paths: changed }); }
|
|
210
|
-
catch { verification = null; }
|
|
211
|
-
const valid = exactRecord(verification, [
|
|
212
|
-
'behavior_equivalent', 'focused_tests_passed', 'sensor_fresh', 'overlap_reduced',
|
|
213
|
-
'evidence_digest',
|
|
214
|
-
]) && verification.behavior_equivalent === true && verification.focused_tests_passed === true
|
|
215
|
-
&& verification.sensor_fresh === true && verification.overlap_reduced === true
|
|
216
|
-
&& isTodoDigest(verification.evidence_digest);
|
|
217
|
-
result = outcome(candidate, valid ? 'accepted' : 'rejected',
|
|
218
|
-
valid ? 'all_gates_passed' : 'verification_failed', changed, verification, false);
|
|
219
|
-
}
|
|
220
|
-
} finally {
|
|
221
|
-
try {
|
|
222
|
-
await run('git', ['worktree', 'remove', '--force', worktreePath], repoRoot);
|
|
223
|
-
await rm(worktreeRoot, { recursive: true, force: true });
|
|
224
|
-
cleanupOk = true;
|
|
225
|
-
} catch { cleanupOk = false; }
|
|
226
|
-
}
|
|
227
|
-
const after = await fingerprint(repoRoot);
|
|
228
|
-
if (before !== after) throw new TypeError('bounded seam runner modified canonical repository');
|
|
229
|
-
return outcome(candidate, result.decision, result.reason, changed, result.verification, cleanupOk);
|
|
230
|
-
}
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
exactRecord, isTodoDigest, isTodoIdentifier, isTodoRef, todoSelfDigest,
|
|
8
|
+
} from './todo-contracts.mjs';
|
|
9
|
+
|
|
10
|
+
const GIT_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
|
|
11
|
+
|
|
12
|
+
function sortedUniqueRefs(value, { nonempty = false } = {}) {
|
|
13
|
+
return Array.isArray(value) && (!nonempty || value.length > 0) && value.every(isTodoRef)
|
|
14
|
+
&& value.every((entry, index) => index === 0 || value[index - 1] < entry);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* v2の三面surface(ADR 0137 Decision 1)。所有だけがtaskへ紐づく。
|
|
19
|
+
*
|
|
20
|
+
* 残余はsymbolを列挙しない。移らなかったものすべてという補集合であり、列挙を要求すると
|
|
21
|
+
* 原fileの全symbol目録が契約の一部になる。
|
|
22
|
+
*/
|
|
23
|
+
function seamSurface(value, taskIds) {
|
|
24
|
+
if (!exactRecord(value, ['role', 'path', 'owner_task_ids', 'symbols'])
|
|
25
|
+
|| !['task_owned', 'shared', 'residual'].includes(value.role)
|
|
26
|
+
|| !isTodoRef(value.path)
|
|
27
|
+
|| !Array.isArray(value.owner_task_ids)
|
|
28
|
+
|| !sortedUniqueSymbols(value.symbols)) return false;
|
|
29
|
+
if (value.role === 'task_owned') {
|
|
30
|
+
return value.owner_task_ids.length === 1 && taskIds.has(value.owner_task_ids[0])
|
|
31
|
+
&& value.symbols.length > 0;
|
|
32
|
+
}
|
|
33
|
+
if (value.owner_task_ids.length !== 0) return false;
|
|
34
|
+
return value.role === 'shared' ? value.symbols.length > 0 : value.symbols.length === 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sortedUniqueSymbols(value) {
|
|
38
|
+
return Array.isArray(value) && value.every(isTodoIdentifier)
|
|
39
|
+
&& value.every((entry, index) => index === 0 || value[index - 1] < entry);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function validateBoundedSeamCandidateV2(value) {
|
|
43
|
+
if (!exactRecord(value, [
|
|
44
|
+
'schema', 'candidate_id', 'base_sha', 'manifest_digest', 'finding_digest',
|
|
45
|
+
'source_path', 'todo_refs', 'surfaces', 'allowed_paths', 'required_paths',
|
|
46
|
+
'verification_policy', 'candidate_digest',
|
|
47
|
+
])
|
|
48
|
+
|| !isTodoIdentifier(value.candidate_id) || !GIT_OID.test(value.base_sha)
|
|
49
|
+
|| !isTodoDigest(value.manifest_digest) || !isTodoDigest(value.finding_digest)
|
|
50
|
+
|| !isTodoRef(value.source_path)
|
|
51
|
+
|| !Array.isArray(value.todo_refs) || value.todo_refs.length < 2
|
|
52
|
+
|| !value.todo_refs.every((entry) => exactRecord(entry, ['plan_key', 'task_id'])
|
|
53
|
+
&& isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.task_id))
|
|
54
|
+
|| !value.todo_refs.every((entry, index) => index === 0
|
|
55
|
+
|| `${value.todo_refs[index - 1].plan_key}\0${value.todo_refs[index - 1].task_id}`
|
|
56
|
+
< `${entry.plan_key}\0${entry.task_id}`)) return false;
|
|
57
|
+
|
|
58
|
+
const taskIds = new Set(value.todo_refs.map(({ task_id: taskId }) => taskId));
|
|
59
|
+
if (!Array.isArray(value.surfaces)
|
|
60
|
+
|| !value.surfaces.every((surface) => seamSurface(surface, taskIds))
|
|
61
|
+
|| !value.surfaces.every((surface, index) => index === 0
|
|
62
|
+
|| value.surfaces[index - 1].path < surface.path)) return false;
|
|
63
|
+
|
|
64
|
+
// 所有面は全taskへ1つずつ。残余は原path上に必ず1つ。共有面は0または1(粒度は未裁定)。
|
|
65
|
+
const owned = value.surfaces.filter(({ role }) => role === 'task_owned');
|
|
66
|
+
const shared = value.surfaces.filter(({ role }) => role === 'shared');
|
|
67
|
+
const residual = value.surfaces.filter(({ role }) => role === 'residual');
|
|
68
|
+
const ownerIds = new Set(owned.flatMap(({ owner_task_ids: ids }) => ids));
|
|
69
|
+
if (owned.length !== taskIds.size || ownerIds.size !== taskIds.size
|
|
70
|
+
|| shared.length > 1 || residual.length !== 1
|
|
71
|
+
|| residual[0].path !== value.source_path) return false;
|
|
72
|
+
|
|
73
|
+
// symbolは1面にしか属せない。二重所有は移動先が決まらない。
|
|
74
|
+
const symbols = value.surfaces.flatMap(({ symbols: list }) => list);
|
|
75
|
+
if (new Set(symbols).size !== symbols.length) return false;
|
|
76
|
+
|
|
77
|
+
if (!sortedUniqueRefs(value.allowed_paths, { nonempty: true })
|
|
78
|
+
|| !sortedUniqueRefs(value.required_paths, { nonempty: true })
|
|
79
|
+
|| !value.required_paths.every((entry) => value.allowed_paths.includes(entry))
|
|
80
|
+
|| !value.surfaces.every(({ path }) => value.allowed_paths.includes(path))
|
|
81
|
+
|| !value.required_paths.includes(value.source_path)
|
|
82
|
+
|| !owned.every(({ path }) => value.required_paths.includes(path))) return false;
|
|
83
|
+
|
|
84
|
+
// ADR 0138の五条件。1つでもfalseなら、その候補は採用条件を緩めた別物である。
|
|
85
|
+
return exactRecord(value.verification_policy, [
|
|
86
|
+
'focused_test_refs', 'require_behavior_equivalence', 'require_fresh_sensor',
|
|
87
|
+
'require_overlap_reduction', 'require_no_new_conflict_pairs',
|
|
88
|
+
'require_parallelism_improvement',
|
|
89
|
+
])
|
|
90
|
+
&& sortedUniqueRefs(value.verification_policy.focused_test_refs, { nonempty: true })
|
|
91
|
+
&& value.verification_policy.require_behavior_equivalence === true
|
|
92
|
+
&& value.verification_policy.require_fresh_sensor === true
|
|
93
|
+
&& value.verification_policy.require_overlap_reduction === true
|
|
94
|
+
&& value.verification_policy.require_no_new_conflict_pairs === true
|
|
95
|
+
&& value.verification_policy.require_parallelism_improvement === true
|
|
96
|
+
&& isTodoDigest(value.candidate_digest)
|
|
97
|
+
&& value.candidate_digest === todoSelfDigest(value, 'candidate_digest');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function validateBoundedSeamCandidate(value) {
|
|
101
|
+
try {
|
|
102
|
+
if (value?.schema === 'lattice.bounded_seam_candidate.v2') {
|
|
103
|
+
return validateBoundedSeamCandidateV2(value);
|
|
104
|
+
}
|
|
105
|
+
return exactRecord(value, [
|
|
106
|
+
'schema', 'candidate_id', 'base_sha', 'manifest_digest', 'finding_digest',
|
|
107
|
+
'todo_refs', 'anchor', 'allowed_paths', 'required_paths', 'verification_policy',
|
|
108
|
+
'candidate_digest',
|
|
109
|
+
]) && value.schema === 'lattice.bounded_seam_candidate.v1'
|
|
110
|
+
&& isTodoIdentifier(value.candidate_id) && GIT_OID.test(value.base_sha)
|
|
111
|
+
&& isTodoDigest(value.manifest_digest) && isTodoDigest(value.finding_digest)
|
|
112
|
+
&& Array.isArray(value.todo_refs) && value.todo_refs.length >= 2
|
|
113
|
+
&& value.todo_refs.every((entry) => exactRecord(entry, ['plan_key', 'task_id'])
|
|
114
|
+
&& isTodoIdentifier(entry.plan_key) && isTodoIdentifier(entry.task_id))
|
|
115
|
+
&& value.todo_refs.every((entry, index) => index === 0
|
|
116
|
+
|| `${value.todo_refs[index - 1].plan_key}\0${value.todo_refs[index - 1].task_id}`
|
|
117
|
+
< `${entry.plan_key}\0${entry.task_id}`)
|
|
118
|
+
&& exactRecord(value.anchor, ['path', 'symbol', 'start_line', 'end_line'])
|
|
119
|
+
&& isTodoRef(value.anchor.path) && isTodoIdentifier(value.anchor.symbol)
|
|
120
|
+
&& Number.isSafeInteger(value.anchor.start_line) && value.anchor.start_line >= 1
|
|
121
|
+
&& Number.isSafeInteger(value.anchor.end_line) && value.anchor.end_line >= value.anchor.start_line
|
|
122
|
+
&& sortedUniqueRefs(value.allowed_paths, { nonempty: true })
|
|
123
|
+
&& sortedUniqueRefs(value.required_paths, { nonempty: true })
|
|
124
|
+
&& value.required_paths.every((entry) => value.allowed_paths.includes(entry))
|
|
125
|
+
&& value.allowed_paths.includes(value.anchor.path)
|
|
126
|
+
&& exactRecord(value.verification_policy, [
|
|
127
|
+
'focused_test_ids', 'require_behavior_equivalence', 'require_fresh_sensor',
|
|
128
|
+
'require_overlap_reduction',
|
|
129
|
+
]) && Array.isArray(value.verification_policy.focused_test_ids)
|
|
130
|
+
&& value.verification_policy.focused_test_ids.length > 0
|
|
131
|
+
&& value.verification_policy.focused_test_ids.every(isTodoIdentifier)
|
|
132
|
+
&& value.verification_policy.focused_test_ids.every((entry, index) => index === 0
|
|
133
|
+
|| value.verification_policy.focused_test_ids[index - 1] < entry)
|
|
134
|
+
&& value.verification_policy.require_behavior_equivalence === true
|
|
135
|
+
&& value.verification_policy.require_fresh_sensor === true
|
|
136
|
+
&& value.verification_policy.require_overlap_reduction === true
|
|
137
|
+
&& isTodoDigest(value.candidate_digest)
|
|
138
|
+
&& value.candidate_digest === todoSelfDigest(value, 'candidate_digest');
|
|
139
|
+
} catch { return false; }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function run(command, args, cwd) {
|
|
143
|
+
return new Promise((resolve, reject) => {
|
|
144
|
+
const child = spawn(command, args, { cwd, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
145
|
+
const stdout = []; const stderr = [];
|
|
146
|
+
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
|
147
|
+
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
|
148
|
+
child.once('error', reject);
|
|
149
|
+
child.once('close', (code, signal) => {
|
|
150
|
+
if (code === 0 && signal === null) resolve(Buffer.concat(stdout));
|
|
151
|
+
else reject(new Error(`${command} failed (${signal ?? code}): ${Buffer.concat(stderr).toString('utf8').trim()}`));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fingerprint(repoRoot) {
|
|
157
|
+
const [head, status, refs] = await Promise.all([
|
|
158
|
+
run('git', ['rev-parse', 'HEAD'], repoRoot),
|
|
159
|
+
run('git', ['status', '--porcelain=v1', '--untracked-files=all'], repoRoot),
|
|
160
|
+
run('git', ['for-each-ref', '--format=%(refname) %(objectname)'], repoRoot),
|
|
161
|
+
]);
|
|
162
|
+
return Buffer.concat([head, status, refs]).toString('hex');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function changedPaths(worktreePath) {
|
|
166
|
+
const bytes = await run('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], worktreePath);
|
|
167
|
+
const fields = bytes.toString('utf8').split('\0').filter(Boolean);
|
|
168
|
+
const paths = [];
|
|
169
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
170
|
+
const field = fields[index];
|
|
171
|
+
const status = field.slice(0, 2);
|
|
172
|
+
paths.push(field.slice(3));
|
|
173
|
+
if (status.includes('R') || status.includes('C')) paths.push(fields[++index]);
|
|
174
|
+
}
|
|
175
|
+
return [...new Set(paths)].sort();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function outcome(candidate, decision, reason, changed, verification, cleanupOk) {
|
|
179
|
+
const result = { schema: 'lattice.bounded_seam_outcome.v1', candidate_id: candidate.candidate_id,
|
|
180
|
+
candidate_digest: candidate.candidate_digest, decision, reason, changed_paths: changed,
|
|
181
|
+
verification, cleanup_ok: cleanupOk, outcome_digest: '' };
|
|
182
|
+
result.outcome_digest = todoSelfDigest(result, 'outcome_digest');
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function runBoundedSeamCandidate({ repoRoot, candidate, transform, verify }) {
|
|
187
|
+
if (!validateBoundedSeamCandidate(candidate)) throw new TypeError('bounded seam candidate invalid');
|
|
188
|
+
if (typeof repoRoot !== 'string' || typeof transform !== 'function' || typeof verify !== 'function') {
|
|
189
|
+
throw new TypeError('bounded seam runner options invalid');
|
|
190
|
+
}
|
|
191
|
+
const before = await fingerprint(repoRoot);
|
|
192
|
+
const head = (await run('git', ['rev-parse', 'HEAD'], repoRoot)).toString('utf8').trim();
|
|
193
|
+
if (head !== candidate.base_sha) return outcome(candidate, 'rejected', 'stale_base', [], null, true);
|
|
194
|
+
const worktreeRoot = await mkdtemp(path.join(tmpdir(), 'lattice-bounded-seam-'));
|
|
195
|
+
const worktreePath = path.join(worktreeRoot, 'tree');
|
|
196
|
+
let changed = [];
|
|
197
|
+
let result;
|
|
198
|
+
let cleanupOk = false;
|
|
199
|
+
try {
|
|
200
|
+
await run('git', ['worktree', 'add', '--detach', worktreePath, candidate.base_sha], repoRoot);
|
|
201
|
+
try { await transform({ worktreePath, candidate: structuredClone(candidate) }); }
|
|
202
|
+
catch { result = outcome(candidate, 'rejected', 'transform_failed', [], null, false); }
|
|
203
|
+
if (result === undefined) changed = await changedPaths(worktreePath);
|
|
204
|
+
if (result === undefined && (changed.some((entry) => !candidate.allowed_paths.includes(entry))
|
|
205
|
+
|| candidate.required_paths.some((entry) => !changed.includes(entry)))) {
|
|
206
|
+
result = outcome(candidate, 'rejected', 'scope_drift', changed, null, false);
|
|
207
|
+
} else if (result === undefined) {
|
|
208
|
+
let verification;
|
|
209
|
+
try { verification = await verify({ worktreePath, candidate: structuredClone(candidate), changed_paths: changed }); }
|
|
210
|
+
catch { verification = null; }
|
|
211
|
+
const valid = exactRecord(verification, [
|
|
212
|
+
'behavior_equivalent', 'focused_tests_passed', 'sensor_fresh', 'overlap_reduced',
|
|
213
|
+
'evidence_digest',
|
|
214
|
+
]) && verification.behavior_equivalent === true && verification.focused_tests_passed === true
|
|
215
|
+
&& verification.sensor_fresh === true && verification.overlap_reduced === true
|
|
216
|
+
&& isTodoDigest(verification.evidence_digest);
|
|
217
|
+
result = outcome(candidate, valid ? 'accepted' : 'rejected',
|
|
218
|
+
valid ? 'all_gates_passed' : 'verification_failed', changed, verification, false);
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
try {
|
|
222
|
+
await run('git', ['worktree', 'remove', '--force', worktreePath], repoRoot);
|
|
223
|
+
await rm(worktreeRoot, { recursive: true, force: true });
|
|
224
|
+
cleanupOk = true;
|
|
225
|
+
} catch { cleanupOk = false; }
|
|
226
|
+
}
|
|
227
|
+
const after = await fingerprint(repoRoot);
|
|
228
|
+
if (before !== after) throw new TypeError('bounded seam runner modified canonical repository');
|
|
229
|
+
return outcome(candidate, result.decision, result.reason, changed, result.verification, cleanupOk);
|
|
230
|
+
}
|