@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
|
@@ -1,640 +1,640 @@
|
|
|
1
|
-
import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-contracts.mjs';
|
|
2
|
-
import {
|
|
3
|
-
TODO_INDEPENDENCE_SCHEMA,
|
|
4
|
-
isGitSha,
|
|
5
|
-
isTodoIndependenceLegacyMarker,
|
|
6
|
-
severabilityOfConflictKind,
|
|
7
|
-
synthesizeWitnessRunRequest,
|
|
8
|
-
validateTodoIndependence,
|
|
9
|
-
validateTodoWitnessSet,
|
|
10
|
-
} from './todo-independence-contracts.mjs';
|
|
11
|
-
import { compileRuntimePlanV1, evidenceFromCollectedOutcomes } from './runtime-front-end.mjs';
|
|
12
|
-
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
13
|
-
|
|
14
|
-
export class TodoIndependenceError extends Error {
|
|
15
|
-
constructor(code, reason, detail = {}) {
|
|
16
|
-
super(reason);
|
|
17
|
-
this.name = 'TodoIndependenceError';
|
|
18
|
-
this.code = code;
|
|
19
|
-
this.detail = { reason, ...detail };
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function fail(code, reason, detail) {
|
|
24
|
-
throw new TodoIndependenceError(code, reason, detail);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* compile一回を識別するid。plan identityとwitness digestだけから決まるので、
|
|
31
|
-
* 同じ入力からは同じrequest bytesが出る。run lifecycleへは登録しない。
|
|
32
|
-
*/
|
|
33
|
-
export function witnessRequestId(witnessSet, planVersion) {
|
|
34
|
-
const digest = digestTodoArtifact({
|
|
35
|
-
schema: 'lattice.todo_independence_request_id.v1',
|
|
36
|
-
project_id: witnessSet.project_id,
|
|
37
|
-
plan_key: witnessSet.plan_key,
|
|
38
|
-
plan_version: planVersion,
|
|
39
|
-
witness_set_digest: witnessSet.witness_set_digest,
|
|
40
|
-
});
|
|
41
|
-
return `independence-${digest.slice(0, 24)}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 実sensorを引いてfront-end evidence契約へ整形する。失敗をunknownへ丸めない。 */
|
|
45
|
-
export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute = undefined }) {
|
|
46
|
-
const querySet = witnessSet.sensor_query_set;
|
|
47
|
-
const collected = await collectSensorEvidence({
|
|
48
|
-
cwd, querySet, ...(execute === undefined ? {} : { execute }),
|
|
49
|
-
});
|
|
50
|
-
return evidenceFromCollectedOutcomes({ querySet, collected });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* conflictから参照されるnormalized resourceを正規化してartifactへ載せる。
|
|
55
|
-
*
|
|
56
|
-
* `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
|
|
57
|
-
* prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
|
|
58
|
-
* 不明のまま記録するのでなくtyped failで止める。
|
|
59
|
-
*/
|
|
60
|
-
function conflictsFrom(verdicts, resources) {
|
|
61
|
-
const resourceById = new Map();
|
|
62
|
-
for (const resource of Array.isArray(resources) ? resources : []) {
|
|
63
|
-
const resourceId = resource?.resource_id;
|
|
64
|
-
if (resourceById.has(resourceId)) {
|
|
65
|
-
const previous = resourceById.get(resourceId);
|
|
66
|
-
if (previous.kind !== resource?.kind) {
|
|
67
|
-
fail('INDEPENDENCE_RESOURCE_KIND_MISMATCH', 'conflict_resource_kind_mismatch', {
|
|
68
|
-
resource_id: resourceId ?? null,
|
|
69
|
-
observed_kinds: [previous.kind ?? null, resource?.kind ?? null],
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
if (previous.target !== resource?.target) {
|
|
73
|
-
fail('INDEPENDENCE_RESOURCE_TARGET_MISMATCH', 'conflict_resource_target_mismatch', {
|
|
74
|
-
resource_id: resourceId ?? null,
|
|
75
|
-
observed_targets: [previous.target ?? null, resource?.target ?? null],
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
fail('INDEPENDENCE_RESOURCE_DUPLICATE', 'conflict_resource_duplicate', {
|
|
79
|
-
resource_id: resourceId ?? null,
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
resourceById.set(resourceId, { kind: resource?.kind, target: resource?.target });
|
|
83
|
-
}
|
|
84
|
-
const conflicts = verdicts
|
|
85
|
-
.filter((verdict) => verdict.type === 'conflict')
|
|
86
|
-
.map((verdict) => {
|
|
87
|
-
const resource = resourceById.get(verdict.resource_id);
|
|
88
|
-
const kind = resource?.kind;
|
|
89
|
-
if (!['symbol', 'path', 'state', 'effect', 'line'].includes(kind)) {
|
|
90
|
-
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
91
|
-
resource_id: verdict.resource_id, observed_kind: kind ?? null,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
if (typeof resource.target !== 'string' || resource.target.length === 0) {
|
|
95
|
-
fail('INDEPENDENCE_RESOURCE_TARGET_UNRESOLVED', 'conflict_resource_target_unresolved', {
|
|
96
|
-
resource_id: verdict.resource_id, observed_target: resource.target ?? null,
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
return {
|
|
100
|
-
task_ids: [...verdict.todo_ids].sort(compareText),
|
|
101
|
-
resource_id: verdict.resource_id,
|
|
102
|
-
};
|
|
103
|
-
})
|
|
104
|
-
.sort((left, right) => compareText(
|
|
105
|
-
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
|
|
106
|
-
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
|
|
107
|
-
));
|
|
108
|
-
const used = new Set(conflicts.map(({ resource_id: resourceId }) => resourceId));
|
|
109
|
-
const conflictResources = [...used]
|
|
110
|
-
.map((resourceId) => ({ resource_id: resourceId, ...resourceById.get(resourceId) }))
|
|
111
|
-
.sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
112
|
-
return { conflicts, conflictResources };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* witnessが宣言した境界pathを集める。
|
|
117
|
-
*
|
|
118
|
-
* 鮮度のdiff交差判定に使うので、observationへ効くpathを漏れなく採る——所有path、
|
|
119
|
-
* 書き込み、読み取り、affected test、sensor queryのexpect path。symbol owns自体は
|
|
120
|
-
* pathを持たないが、それを裏取りするqueryのexpect pathが同じ効果を持つ。
|
|
121
|
-
*/
|
|
122
|
-
function boundaryPathsOf(witness) {
|
|
123
|
-
const paths = new Set();
|
|
124
|
-
for (const own of witness.owns) {
|
|
125
|
-
if (own.kind === 'path') paths.add(own.target);
|
|
126
|
-
}
|
|
127
|
-
for (const path of witness.writes) paths.add(path);
|
|
128
|
-
for (const path of witness.reads) paths.add(path);
|
|
129
|
-
for (const path of witness.affected_tests) paths.add(path);
|
|
130
|
-
for (const entry of witness.sensor_provenance.queries) paths.add(entry.expect.path);
|
|
131
|
-
for (const line of witness.lines ?? []) {
|
|
132
|
-
for (const anchor of line.anchors) paths.add(anchor.path);
|
|
133
|
-
}
|
|
134
|
-
return [...paths].sort(compareText);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function precedencesFrom(verdicts) {
|
|
138
|
-
return verdicts
|
|
139
|
-
.filter((verdict) => verdict.type === 'precedence')
|
|
140
|
-
.map((verdict) => ({
|
|
141
|
-
from_task_id: verdict.from_todo_id,
|
|
142
|
-
to_task_id: verdict.to_todo_id,
|
|
143
|
-
reason: verdict.reason,
|
|
144
|
-
}))
|
|
145
|
-
.sort((left, right) => compareText(
|
|
146
|
-
`${left.from_task_id}\0${left.to_task_id}\0${left.reason}`,
|
|
147
|
-
`${right.from_task_id}\0${right.to_task_id}\0${right.reason}`,
|
|
148
|
-
));
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function unknownsFrom(detail) {
|
|
152
|
-
const entries = [
|
|
153
|
-
...(Array.isArray(detail?.unknowns) ? detail.unknowns : []),
|
|
154
|
-
...(Array.isArray(detail?.unresolved_witnesses) ? detail.unresolved_witnesses : []),
|
|
155
|
-
]
|
|
156
|
-
.filter((entry) => entry !== null && typeof entry === 'object'
|
|
157
|
-
&& typeof entry.todo_id === 'string' && typeof entry.kind === 'string'
|
|
158
|
-
&& typeof entry.ref === 'string')
|
|
159
|
-
.map((entry) => ({
|
|
160
|
-
task_id: entry.todo_id,
|
|
161
|
-
kind: entry.kind,
|
|
162
|
-
ref: entry.ref,
|
|
163
|
-
}));
|
|
164
|
-
const seen = new Set();
|
|
165
|
-
return entries
|
|
166
|
-
.filter((entry) => {
|
|
167
|
-
const key = `${entry.task_id}\0${entry.kind}\0${entry.ref}`;
|
|
168
|
-
if (seen.has(key)) return false;
|
|
169
|
-
seen.add(key);
|
|
170
|
-
return true;
|
|
171
|
-
})
|
|
172
|
-
.sort((left, right) => compareText(
|
|
173
|
-
`${left.task_id}\0${left.kind}\0${left.ref}`,
|
|
174
|
-
`${right.task_id}\0${right.kind}\0${right.ref}`,
|
|
175
|
-
));
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* witness setとsensor evidenceから`lattice.todo_independence.v5`を作る。
|
|
180
|
-
*
|
|
181
|
-
* compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
|
|
182
|
-
* unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
|
|
183
|
-
* `dispatchable`ならpairwise verdictとwave planを写し、`BOUNDARY_UNKNOWN`なら
|
|
184
|
-
* unknownをtask単位で記録してwave planを持たない。それ以外のnon-dispatchableは
|
|
185
|
-
* 契約違反または観測不成立であり、artifactへ丸めずtyped errorで止める。
|
|
186
|
-
*/
|
|
187
|
-
/**
|
|
188
|
-
* 前回artifactと今回の宣言を突き合わせて、task別の膨張を事実だけ記録する(s1)。
|
|
189
|
-
*
|
|
190
|
-
* **判定しない。** 上流の契約確定に追従した膨張・自分の変更の後始末・元から在った面の見落とし・
|
|
191
|
-
* 思いつきで盛った分は、**機械には区別できない**(2026-08-09の実測: 1つのtaskへ前3種が同時に出た)。
|
|
192
|
-
* 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までで、仕分けはAIがやる。
|
|
193
|
-
*
|
|
194
|
-
* 前回が無い(初回・plan version違い・legacy版)時は、比較相手なしとして今回の宣言を初回として置く。
|
|
195
|
-
* **「前回が読めなかった」を「膨張ゼロ」に化かさない**ため、compared_witness_digestで区別できる。
|
|
196
|
-
*/
|
|
197
|
-
function scopeExpansionFrom({ taskIds, boundaries, previousArtifact, plan }) {
|
|
198
|
-
const comparable = previousArtifact !== null && previousArtifact !== undefined
|
|
199
|
-
&& previousArtifact.schema === TODO_INDEPENDENCE_SCHEMA
|
|
200
|
-
&& previousArtifact.plan_key === plan.plan_key
|
|
201
|
-
&& previousArtifact.plan_version === plan.plan_version
|
|
202
|
-
&& previousArtifact.topology_digest === plan.topology_digest;
|
|
203
|
-
const previousBoundaries = new Map(comparable
|
|
204
|
-
? previousArtifact.task_boundaries.map((entry) => [entry.task_id, entry.paths])
|
|
205
|
-
: []);
|
|
206
|
-
const previousExpansion = new Map(comparable
|
|
207
|
-
? previousArtifact.scope_expanded.map((entry) => [entry.task_id, entry])
|
|
208
|
-
: []);
|
|
209
|
-
// 合流点(gate形)はplanのtopologyが持つ事実で、宣言とは独立に決まる。
|
|
210
|
-
const incoming = new Map(taskIds.map((taskId) => [taskId, 0]));
|
|
211
|
-
for (const edge of plan.hard_dependencies ?? []) {
|
|
212
|
-
const to = edge?.to?.task_id ?? edge?.to_task_id;
|
|
213
|
-
if (incoming.has(to)) incoming.set(to, incoming.get(to) + 1);
|
|
214
|
-
}
|
|
215
|
-
return taskIds.map((taskId) => {
|
|
216
|
-
const current = boundaries.get(taskId) ?? [];
|
|
217
|
-
const previous = previousBoundaries.get(taskId) ?? null;
|
|
218
|
-
const prior = previousExpansion.get(taskId) ?? null;
|
|
219
|
-
// **subset入替を「初回」へ偽装しない。** witnessはwave単位のsubsetで書き出せるので、
|
|
220
|
-
// A→B→Aと入れ替わると直前artifactにAが居ない。そこで今回を初回と置くと、
|
|
221
|
-
// **本当の初回宣言数と膨張回数が消える**(suzune の監査で実測・room [1152])。
|
|
222
|
-
// 比較相手はあるのにこのtaskが居ない時は、**数を主張せずnullで「分からない」と言う。**
|
|
223
|
-
if (comparable && previous === null) {
|
|
224
|
-
return {
|
|
225
|
-
task_id: taskId,
|
|
226
|
-
compared_witness_digest: previousArtifact.witness_set_digest,
|
|
227
|
-
first_seen_path_count: null,
|
|
228
|
-
path_count: current.length,
|
|
229
|
-
added_paths: [],
|
|
230
|
-
removed_paths: [],
|
|
231
|
-
growth_events: null,
|
|
232
|
-
gate_shape: (incoming.get(taskId) ?? 0) >= 2,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
const currentSet = new Set(current);
|
|
236
|
-
const previousSet = new Set(previous ?? []);
|
|
237
|
-
const added = previous === null ? [] : current.filter((path) => !previousSet.has(path));
|
|
238
|
-
const removed = previous === null ? [] : previous.filter((path) => !currentSet.has(path));
|
|
239
|
-
return {
|
|
240
|
-
task_id: taskId,
|
|
241
|
-
compared_witness_digest: comparable ? previousArtifact.witness_set_digest : null,
|
|
242
|
-
// **unknown は後続へ伝播する。** 一度 subset gap で累計が分からなくなった task は、
|
|
243
|
-
// 次の compile で `(null ?? 0) + 1` のように既知へ戻してはいけない——
|
|
244
|
-
// **欠落前の累計が分からないことは、その後も分からないまま**である(suzune [1173])。
|
|
245
|
-
first_seen_path_count: prior === null
|
|
246
|
-
? current.length
|
|
247
|
-
: prior.first_seen_path_count,
|
|
248
|
-
path_count: current.length,
|
|
249
|
-
added_paths: [...added].sort(compareText),
|
|
250
|
-
removed_paths: [...removed].sort(compareText),
|
|
251
|
-
growth_events: prior !== null && prior.growth_events === null
|
|
252
|
-
? null
|
|
253
|
-
: (prior?.growth_events ?? 0) + (added.length > 0 ? 1 : 0),
|
|
254
|
-
gate_shape: (incoming.get(taskId) ?? 0) >= 2,
|
|
255
|
-
};
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
export function compileTodoIndependence(options = {}) {
|
|
260
|
-
const { witnessSet, plan, baseSha, compiledAt, sensorEvidence, previousArtifact = null } = options;
|
|
261
|
-
if (!validateTodoWitnessSet(witnessSet)) {
|
|
262
|
-
fail('INDEPENDENCE_WITNESS_INVALID', 'witness_set_invalid');
|
|
263
|
-
}
|
|
264
|
-
if (!isGitSha(baseSha)) fail('INDEPENDENCE_BASE_INVALID', 'base_sha_invalid');
|
|
265
|
-
if (witnessSet.project_id !== plan.project_id || witnessSet.plan_key !== plan.plan_key) {
|
|
266
|
-
fail('INDEPENDENCE_PLAN_MISMATCH', 'witness_set_plan_mismatch', {
|
|
267
|
-
witness_plan_key: witnessSet.plan_key, plan_key: plan.plan_key,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const planTaskIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
|
|
272
|
-
const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
|
|
273
|
-
const absent = taskIds.filter((taskId) => !planTaskIds.has(taskId));
|
|
274
|
-
if (absent.length > 0) {
|
|
275
|
-
fail('INDEPENDENCE_TASK_ABSENT', 'witness_task_absent_from_plan', { task_ids: absent });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const request = synthesizeWitnessRunRequest(witnessSet, {
|
|
279
|
-
baseSha, requestId: witnessRequestId(witnessSet, plan.plan_version),
|
|
280
|
-
});
|
|
281
|
-
const compiled = compileRuntimePlanV1({
|
|
282
|
-
request,
|
|
283
|
-
sensorEvidence,
|
|
284
|
-
planRef: witnessRequestId(witnessSet, plan.plan_version),
|
|
285
|
-
planEpoch: 0,
|
|
286
|
-
predecessorRefs: [],
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
if (compiled.outcome !== 'dispatchable'
|
|
290
|
-
&& !(compiled.outcome === 'non_dispatchable' && compiled.code === 'BOUNDARY_UNKNOWN')) {
|
|
291
|
-
fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_compile_not_dispatchable', {
|
|
292
|
-
code: compiled.code ?? null, compile_detail: compiled.detail ?? null,
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const dispatchable = compiled.outcome === 'dispatchable';
|
|
297
|
-
const conflictProjection = dispatchable
|
|
298
|
-
? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
|
|
299
|
-
: { conflicts: [], conflictResources: [] };
|
|
300
|
-
const boundaryEntries = taskIds.map((taskId) => ({
|
|
301
|
-
task_id: taskId,
|
|
302
|
-
paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
|
|
303
|
-
}));
|
|
304
|
-
const artifact = {
|
|
305
|
-
schema: TODO_INDEPENDENCE_SCHEMA,
|
|
306
|
-
project_id: plan.project_id,
|
|
307
|
-
plan_key: plan.plan_key,
|
|
308
|
-
plan_version: plan.plan_version,
|
|
309
|
-
topology_digest: plan.topology_digest,
|
|
310
|
-
base_sha: baseSha,
|
|
311
|
-
witness_set_digest: witnessSet.witness_set_digest,
|
|
312
|
-
compiled_at: compiledAt,
|
|
313
|
-
task_ids: taskIds,
|
|
314
|
-
task_boundaries: boundaryEntries,
|
|
315
|
-
conflict_resources: conflictProjection.conflictResources,
|
|
316
|
-
conflicts: conflictProjection.conflicts,
|
|
317
|
-
precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
|
|
318
|
-
unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
|
|
319
|
-
wave_plan: dispatchable
|
|
320
|
-
? {
|
|
321
|
-
waves: compiled.schedule.waves.map((wave) => ({
|
|
322
|
-
task_ids: [...wave.todo_ids].sort(compareText),
|
|
323
|
-
})),
|
|
324
|
-
minimum_feasible_waves: compiled.schedule.minimum_feasible_waves,
|
|
325
|
-
}
|
|
326
|
-
: null,
|
|
327
|
-
scope_expanded: scopeExpansionFrom({
|
|
328
|
-
taskIds,
|
|
329
|
-
boundaries: new Map(boundaryEntries.map((entry) => [entry.task_id, entry.paths])),
|
|
330
|
-
previousArtifact,
|
|
331
|
-
plan,
|
|
332
|
-
}),
|
|
333
|
-
outcome: dispatchable ? 'compiled' : 'unknown',
|
|
334
|
-
result_digest: '',
|
|
335
|
-
};
|
|
336
|
-
artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
|
|
337
|
-
|
|
338
|
-
// unknownを1件も持たないのに非dispatchableという状態は、原因を失った成功扱いになる。
|
|
339
|
-
if (!dispatchable && artifact.unknowns.length === 0) {
|
|
340
|
-
fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_unknown_without_cause', {
|
|
341
|
-
compile_detail: compiled.detail ?? null,
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
if (!validateTodoIndependence(artifact)) {
|
|
345
|
-
fail('INDEPENDENCE_ARTIFACT_INVALID', 'independence_artifact_invalid');
|
|
346
|
-
}
|
|
347
|
-
return artifact;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
function pairKey(left, right) {
|
|
351
|
-
return left < right ? `${left}\0${right}` : `${right}\0${left}`;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 変更pathが宣言境界に触れたかを判定する。
|
|
356
|
-
*
|
|
357
|
-
* 完全一致か、宣言側が`dir/`形式のprefixで変更pathを覆う場合に交差とみなす
|
|
358
|
-
* (runtime diff observerのscope判定と同一規則)。
|
|
359
|
-
*/
|
|
360
|
-
function boundaryTouches(declaredPaths, changedPath) {
|
|
361
|
-
return declaredPaths.some((declared) => declared === changedPath
|
|
362
|
-
|| (declared.endsWith('/') && changedPath.startsWith(declared)));
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* ready frontierを、記録済みartifactだけを根拠に
|
|
367
|
-
* 「検証済み独立」「直列化すべき組」「進行中との競合」「未検査」へ分けて投影する。
|
|
368
|
-
*
|
|
369
|
-
* sensorは引かない。artifactが無い/planが進んだ場合はcoverageで示す。
|
|
370
|
-
* HEADが動いた場合は`changedPaths`と宣言境界の交差を見て、交差したtaskだけを未検査へ落とす
|
|
371
|
-
* (ADR 0128 Decision 4)。宣言境界に触れないdiffは観測を変えないため、
|
|
372
|
-
* 交差しなかったtaskのverified独立は維持する。
|
|
373
|
-
*
|
|
374
|
-
* @param {object} options
|
|
375
|
-
* @param {object|null} options.artifact 記録済みindependence artifact(v2)
|
|
376
|
-
* @param {string[]} options.readyTaskIds 同一plan内のready task
|
|
377
|
-
* @param {string[]} options.activeTaskIds 同一plan内のin-progress task
|
|
378
|
-
* @param {object} options.plan plan_versionとtopology_digestを持つactive plan
|
|
379
|
-
* @param {string} options.currentBaseSha 現在のHEAD
|
|
380
|
-
* @param {string[]|null} options.changedPaths base_sha..HEADの変更path。
|
|
381
|
-
* nullはdiffを確定できなかったこと(base到達不能)を意味し、全taskを交差扱いにする。
|
|
382
|
-
*/
|
|
383
|
-
export function projectIndependenceFrontier({
|
|
384
|
-
artifact, readyTaskIds, activeTaskIds = [], plan, currentBaseSha, changedPaths = null,
|
|
385
|
-
}) {
|
|
386
|
-
const ready = [...readyTaskIds].sort(compareText);
|
|
387
|
-
const active = [...activeTaskIds].sort(compareText);
|
|
388
|
-
if (!isGitSha(currentBaseSha)) fail('INDEPENDENCE_BASE_INVALID', 'current_base_sha_invalid');
|
|
389
|
-
|
|
390
|
-
const emptyFrontier = () => ({
|
|
391
|
-
parallel_groups: [],
|
|
392
|
-
serialize_pairs: [],
|
|
393
|
-
conflicts_with_active: [],
|
|
394
|
-
unknown: ready.map((taskId) => ({
|
|
395
|
-
task_id: taskId,
|
|
396
|
-
unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
|
|
397
|
-
})),
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
if (artifact === null) {
|
|
401
|
-
return {
|
|
402
|
-
coverage: 'missing',
|
|
403
|
-
drift: null,
|
|
404
|
-
active_task_ids: active,
|
|
405
|
-
uncovered_active_task_ids: active,
|
|
406
|
-
frontier: emptyFrontier(),
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (isTodoIndependenceLegacyMarker(artifact)) {
|
|
411
|
-
return {
|
|
412
|
-
coverage: 'superseded',
|
|
413
|
-
drift: null,
|
|
414
|
-
active_task_ids: active,
|
|
415
|
-
uncovered_active_task_ids: active,
|
|
416
|
-
frontier: {
|
|
417
|
-
parallel_groups: [],
|
|
418
|
-
serialize_pairs: [],
|
|
419
|
-
conflicts_with_active: [],
|
|
420
|
-
unknown: ready.map((taskId) => ({
|
|
421
|
-
task_id: taskId,
|
|
422
|
-
unknowns: [{ kind: 'record_superseded', ref: artifact.legacy_schema }],
|
|
423
|
-
})),
|
|
424
|
-
},
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const superseded = artifact.plan_version !== plan.plan_version
|
|
429
|
-
|| artifact.topology_digest !== plan.topology_digest;
|
|
430
|
-
const coverage = superseded ? 'superseded'
|
|
431
|
-
: artifact.base_sha === currentBaseSha ? 'verified' : 'stale';
|
|
432
|
-
|
|
433
|
-
const covered = new Set(artifact.task_ids);
|
|
434
|
-
const boundariesByTask = new Map(artifact.task_boundaries
|
|
435
|
-
.map((entry) => [entry.task_id, entry.paths]));
|
|
436
|
-
|
|
437
|
-
// 宣言境界とdiffの交差。coverage==='stale'のときだけ意味を持つ。
|
|
438
|
-
let drift = null;
|
|
439
|
-
const intersecting = new Set();
|
|
440
|
-
if (coverage === 'stale') {
|
|
441
|
-
const baseReachable = changedPaths !== null;
|
|
442
|
-
if (!baseReachable) {
|
|
443
|
-
// 差分を確定できないなら、記録が今も有効だと主張する根拠が無い。
|
|
444
|
-
for (const taskId of artifact.task_ids) intersecting.add(taskId);
|
|
445
|
-
} else {
|
|
446
|
-
for (const taskId of artifact.task_ids) {
|
|
447
|
-
const declared = boundariesByTask.get(taskId) ?? [];
|
|
448
|
-
if (changedPaths.some((changed) => boundaryTouches(declared, changed))) {
|
|
449
|
-
intersecting.add(taskId);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
drift = {
|
|
454
|
-
base_reachable: baseReachable,
|
|
455
|
-
changed_path_count: baseReachable ? changedPaths.length : 0,
|
|
456
|
-
intersecting_task_ids: [...intersecting].sort(compareText),
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const unknownByTask = new Map();
|
|
461
|
-
const noteUnknown = (taskId, kind, ref) => {
|
|
462
|
-
if (!unknownByTask.has(taskId)) unknownByTask.set(taskId, []);
|
|
463
|
-
unknownByTask.get(taskId).push({ kind, ref });
|
|
464
|
-
};
|
|
465
|
-
for (const taskId of ready) {
|
|
466
|
-
if (!covered.has(taskId)) noteUnknown(taskId, 'witness_missing', 'task_not_in_witness_set');
|
|
467
|
-
}
|
|
468
|
-
for (const entry of artifact.unknowns) {
|
|
469
|
-
if (ready.includes(entry.task_id)) noteUnknown(entry.task_id, entry.kind, entry.ref);
|
|
470
|
-
}
|
|
471
|
-
// 記録全体がverdictを持たない場合、どのpairについても判定は存在しない。
|
|
472
|
-
//
|
|
473
|
-
// compileがBOUNDARY_UNKNOWNで止まると、front endはpairwise verdictを1つも返さないので
|
|
474
|
-
// artifact.conflictsは空になる。この空をそのまま読むと、自分にunknownが無いreadyどうしが
|
|
475
|
-
// 「ぶつかる記録が無い=独立」として並列グループへ入る。実際には**誰についても判定していない**。
|
|
476
|
-
// 新規fileを1つ宣言したToDoが混ざるだけで、無関係なToDoの実competitionが消えて
|
|
477
|
-
// 検証済み並列に見える——不在を証拠に読み替える、最も危険な向きの誤りである。
|
|
478
|
-
if (artifact.outcome !== 'compiled') {
|
|
479
|
-
for (const taskId of ready) {
|
|
480
|
-
if (covered.has(taskId)) noteUnknown(taskId, 'plan_verdicts_absent', artifact.outcome);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
// planが進んだ記録はtask単位に救えない(topology自体が別物)。
|
|
484
|
-
// HEADだけが進んだ場合は、宣言境界に触れたtaskだけを落とす。
|
|
485
|
-
if (coverage === 'superseded') {
|
|
486
|
-
for (const taskId of ready) {
|
|
487
|
-
if (covered.has(taskId)) noteUnknown(taskId, 'record_superseded', artifact.base_sha);
|
|
488
|
-
}
|
|
489
|
-
} else if (coverage === 'stale') {
|
|
490
|
-
for (const taskId of ready) {
|
|
491
|
-
if (covered.has(taskId) && intersecting.has(taskId)) {
|
|
492
|
-
noteUnknown(taskId, 'record_stale', artifact.base_sha);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// 記録が今のtaskへ有効か。無効な相手との競合は「競合なし」でなく「判定不能」であり、
|
|
498
|
-
// conflicts_with_activeではなくuncovered_activeとして示す。
|
|
499
|
-
const usable = (taskId) => covered.has(taskId)
|
|
500
|
-
&& !(coverage === 'superseded')
|
|
501
|
-
&& !(coverage === 'stale' && intersecting.has(taskId));
|
|
502
|
-
const uncoveredActive = active.filter((taskId) => !usable(taskId));
|
|
503
|
-
|
|
504
|
-
const readySet = new Set(ready);
|
|
505
|
-
const activeSet = new Set(active);
|
|
506
|
-
const serializePairs = [];
|
|
507
|
-
const activeConflicts = [];
|
|
508
|
-
const blocked = new Set();
|
|
509
|
-
|
|
510
|
-
const notePair = (left, right, type, detail, kind) => {
|
|
511
|
-
const severability = type === 'conflict' ? severabilityOfConflictKind(kind) : 'serial';
|
|
512
|
-
const pairKind = type === 'conflict' ? kind : null;
|
|
513
|
-
if (readySet.has(left) && readySet.has(right)) {
|
|
514
|
-
const pair = [left, right].sort(compareText);
|
|
515
|
-
serializePairs.push({
|
|
516
|
-
task_ids: pair, type, detail, kind: pairKind, severability,
|
|
517
|
-
});
|
|
518
|
-
blocked.add(pairKey(left, right));
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
// 片端がactiveなら着手時の警告面へ回す。v1はここで黙って捨てていた。
|
|
522
|
-
const readyId = readySet.has(left) ? left : readySet.has(right) ? right : null;
|
|
523
|
-
const activeId = activeSet.has(left) ? left : activeSet.has(right) ? right : null;
|
|
524
|
-
if (readyId === null || activeId === null || readyId === activeId) return;
|
|
525
|
-
if (!usable(readyId) || !usable(activeId)) return;
|
|
526
|
-
activeConflicts.push({
|
|
527
|
-
ready_task_id: readyId, active_task_id: activeId, type, detail,
|
|
528
|
-
kind: pairKind, severability,
|
|
529
|
-
});
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
const conflictResourceById = new Map(artifact.conflict_resources
|
|
533
|
-
.map((resource) => [resource.resource_id, resource]));
|
|
534
|
-
for (const conflict of artifact.conflicts) {
|
|
535
|
-
const resource = conflictResourceById.get(conflict.resource_id);
|
|
536
|
-
if (resource === undefined) {
|
|
537
|
-
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
538
|
-
resource_id: conflict.resource_id, observed_kind: null,
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
|
|
542
|
-
resource.kind);
|
|
543
|
-
}
|
|
544
|
-
for (const precedence of artifact.precedences) {
|
|
545
|
-
notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
|
|
546
|
-
}
|
|
547
|
-
serializePairs.sort((left, right) => compareText(
|
|
548
|
-
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.type}\0${left.detail}`,
|
|
549
|
-
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.type}\0${right.detail}`,
|
|
550
|
-
));
|
|
551
|
-
activeConflicts.sort((left, right) => compareText(
|
|
552
|
-
`${left.ready_task_id}\0${left.active_task_id}\0${left.type}\0${left.detail}`,
|
|
553
|
-
`${right.ready_task_id}\0${right.active_task_id}\0${right.type}\0${right.detail}`,
|
|
554
|
-
));
|
|
555
|
-
|
|
556
|
-
// 未検査を並列グループへ入れない。verdictの不在を独立の証拠にできるのは、
|
|
557
|
-
// 両taskがcompile対象であり、どちらにもunknownが無いときだけである。
|
|
558
|
-
const eligible = ready.filter((taskId) => !unknownByTask.has(taskId));
|
|
559
|
-
const groups = [];
|
|
560
|
-
for (const taskId of eligible) {
|
|
561
|
-
const target = groups.find((group) => group.every((member) => !blocked.has(pairKey(member, taskId))));
|
|
562
|
-
if (target === undefined) groups.push([taskId]); else target.push(taskId);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
return {
|
|
566
|
-
coverage,
|
|
567
|
-
drift,
|
|
568
|
-
active_task_ids: active,
|
|
569
|
-
uncovered_active_task_ids: uncoveredActive,
|
|
570
|
-
frontier: {
|
|
571
|
-
parallel_groups: groups.map((task_ids) => ({ task_ids: [...task_ids].sort(compareText) }))
|
|
572
|
-
.sort((left, right) => compareText(left.task_ids[0], right.task_ids[0])),
|
|
573
|
-
serialize_pairs: serializePairs,
|
|
574
|
-
conflicts_with_active: activeConflicts,
|
|
575
|
-
unknown: [...unknownByTask.entries()]
|
|
576
|
-
.map(([taskId, unknowns]) => ({ task_id: taskId, unknowns }))
|
|
577
|
-
.sort((left, right) => compareText(left.task_id, right.task_id)),
|
|
578
|
-
},
|
|
579
|
-
};
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
export function isIndependenceIdentifier(value) {
|
|
583
|
-
return isTodoIdentifier(value);
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
/**
|
|
587
|
-
* revisionのtask migrationでwitness宣言のtask_idを写す(ADR 0128 Decision 6)。
|
|
588
|
-
*
|
|
589
|
-
* 写像だけを担い、宣言内容が改訂後も意味的に妥当かは主張しない——それは機械には判定できない。
|
|
590
|
-
* 解決できないIDはfail closedにする。既に新IDになっている宣言はそのまま通す(冪等)。
|
|
591
|
-
*
|
|
592
|
-
* @param {object} options
|
|
593
|
-
* @param {object} options.witnessSet 現在の宣言
|
|
594
|
-
* @param {Array<{from_task_id: string, to_task_id: string}>} options.taskMigration
|
|
595
|
-
* @param {string[]} options.planTaskIds 移行後planのtask_id集合
|
|
596
|
-
*/
|
|
597
|
-
export function migrateWitnessSetTaskIds({ witnessSet, taskMigration, planTaskIds }) {
|
|
598
|
-
const mapping = new Map(taskMigration.map((entry) => [entry.from_task_id, entry.to_task_id]));
|
|
599
|
-
const current = new Set(planTaskIds);
|
|
600
|
-
const migrated = {};
|
|
601
|
-
const unresolved = [];
|
|
602
|
-
let migratedCount = 0;
|
|
603
|
-
let removedCount = 0;
|
|
604
|
-
let unchangedCount = 0;
|
|
605
|
-
|
|
606
|
-
for (const taskId of Object.keys(witnessSet.manual_witness).sort(compareText)) {
|
|
607
|
-
const witness = witnessSet.manual_witness[taskId];
|
|
608
|
-
if (mapping.has(taskId)) {
|
|
609
|
-
const target = mapping.get(taskId);
|
|
610
|
-
if (target === 'removed') { removedCount += 1; continue; }
|
|
611
|
-
migrated[target] = witness;
|
|
612
|
-
if (target === taskId) unchangedCount += 1; else migratedCount += 1;
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
// 移行表に無くても、既に現planのtaskなら再実行とみなして通す。
|
|
616
|
-
if (current.has(taskId)) { migrated[taskId] = witness; unchangedCount += 1; continue; }
|
|
617
|
-
unresolved.push(taskId);
|
|
618
|
-
}
|
|
619
|
-
if (unresolved.length > 0) {
|
|
620
|
-
fail('WITNESS_MIGRATION_UNRESOLVED', 'witness_task_id_unresolved', {
|
|
621
|
-
task_ids: unresolved.sort(compareText),
|
|
622
|
-
next_action: 'update_witness_set_manually',
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
if (Object.keys(migrated).length === 0) {
|
|
626
|
-
fail('WITNESS_MIGRATION_EMPTY', 'witness_set_would_be_empty');
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
const next = { ...witnessSet, manual_witness: migrated, witness_set_digest: '' };
|
|
630
|
-
next.witness_set_digest = todoSelfDigest(next, 'witness_set_digest');
|
|
631
|
-
if (!validateTodoWitnessSet(next)) {
|
|
632
|
-
fail('INVALID_TODO_WITNESS_SET', 'migrated_witness_set_invalid');
|
|
633
|
-
}
|
|
634
|
-
return {
|
|
635
|
-
witnessSet: next,
|
|
636
|
-
migrated_count: migratedCount,
|
|
637
|
-
removed_count: removedCount,
|
|
638
|
-
unchanged_count: unchangedCount,
|
|
639
|
-
};
|
|
640
|
-
}
|
|
1
|
+
import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-contracts.mjs';
|
|
2
|
+
import {
|
|
3
|
+
TODO_INDEPENDENCE_SCHEMA,
|
|
4
|
+
isGitSha,
|
|
5
|
+
isTodoIndependenceLegacyMarker,
|
|
6
|
+
severabilityOfConflictKind,
|
|
7
|
+
synthesizeWitnessRunRequest,
|
|
8
|
+
validateTodoIndependence,
|
|
9
|
+
validateTodoWitnessSet,
|
|
10
|
+
} from './todo-independence-contracts.mjs';
|
|
11
|
+
import { compileRuntimePlanV1, evidenceFromCollectedOutcomes } from './runtime-front-end.mjs';
|
|
12
|
+
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
13
|
+
|
|
14
|
+
export class TodoIndependenceError extends Error {
|
|
15
|
+
constructor(code, reason, detail = {}) {
|
|
16
|
+
super(reason);
|
|
17
|
+
this.name = 'TodoIndependenceError';
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.detail = { reason, ...detail };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fail(code, reason, detail) {
|
|
24
|
+
throw new TodoIndependenceError(code, reason, detail);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* compile一回を識別するid。plan identityとwitness digestだけから決まるので、
|
|
31
|
+
* 同じ入力からは同じrequest bytesが出る。run lifecycleへは登録しない。
|
|
32
|
+
*/
|
|
33
|
+
export function witnessRequestId(witnessSet, planVersion) {
|
|
34
|
+
const digest = digestTodoArtifact({
|
|
35
|
+
schema: 'lattice.todo_independence_request_id.v1',
|
|
36
|
+
project_id: witnessSet.project_id,
|
|
37
|
+
plan_key: witnessSet.plan_key,
|
|
38
|
+
plan_version: planVersion,
|
|
39
|
+
witness_set_digest: witnessSet.witness_set_digest,
|
|
40
|
+
});
|
|
41
|
+
return `independence-${digest.slice(0, 24)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 実sensorを引いてfront-end evidence契約へ整形する。失敗をunknownへ丸めない。 */
|
|
45
|
+
export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute = undefined }) {
|
|
46
|
+
const querySet = witnessSet.sensor_query_set;
|
|
47
|
+
const collected = await collectSensorEvidence({
|
|
48
|
+
cwd, querySet, ...(execute === undefined ? {} : { execute }),
|
|
49
|
+
});
|
|
50
|
+
return evidenceFromCollectedOutcomes({ querySet, collected });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* conflictから参照されるnormalized resourceを正規化してartifactへ載せる。
|
|
55
|
+
*
|
|
56
|
+
* `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
|
|
57
|
+
* prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
|
|
58
|
+
* 不明のまま記録するのでなくtyped failで止める。
|
|
59
|
+
*/
|
|
60
|
+
function conflictsFrom(verdicts, resources) {
|
|
61
|
+
const resourceById = new Map();
|
|
62
|
+
for (const resource of Array.isArray(resources) ? resources : []) {
|
|
63
|
+
const resourceId = resource?.resource_id;
|
|
64
|
+
if (resourceById.has(resourceId)) {
|
|
65
|
+
const previous = resourceById.get(resourceId);
|
|
66
|
+
if (previous.kind !== resource?.kind) {
|
|
67
|
+
fail('INDEPENDENCE_RESOURCE_KIND_MISMATCH', 'conflict_resource_kind_mismatch', {
|
|
68
|
+
resource_id: resourceId ?? null,
|
|
69
|
+
observed_kinds: [previous.kind ?? null, resource?.kind ?? null],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (previous.target !== resource?.target) {
|
|
73
|
+
fail('INDEPENDENCE_RESOURCE_TARGET_MISMATCH', 'conflict_resource_target_mismatch', {
|
|
74
|
+
resource_id: resourceId ?? null,
|
|
75
|
+
observed_targets: [previous.target ?? null, resource?.target ?? null],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
fail('INDEPENDENCE_RESOURCE_DUPLICATE', 'conflict_resource_duplicate', {
|
|
79
|
+
resource_id: resourceId ?? null,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
resourceById.set(resourceId, { kind: resource?.kind, target: resource?.target });
|
|
83
|
+
}
|
|
84
|
+
const conflicts = verdicts
|
|
85
|
+
.filter((verdict) => verdict.type === 'conflict')
|
|
86
|
+
.map((verdict) => {
|
|
87
|
+
const resource = resourceById.get(verdict.resource_id);
|
|
88
|
+
const kind = resource?.kind;
|
|
89
|
+
if (!['symbol', 'path', 'state', 'effect', 'line'].includes(kind)) {
|
|
90
|
+
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
91
|
+
resource_id: verdict.resource_id, observed_kind: kind ?? null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (typeof resource.target !== 'string' || resource.target.length === 0) {
|
|
95
|
+
fail('INDEPENDENCE_RESOURCE_TARGET_UNRESOLVED', 'conflict_resource_target_unresolved', {
|
|
96
|
+
resource_id: verdict.resource_id, observed_target: resource.target ?? null,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
task_ids: [...verdict.todo_ids].sort(compareText),
|
|
101
|
+
resource_id: verdict.resource_id,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.sort((left, right) => compareText(
|
|
105
|
+
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
|
|
106
|
+
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
|
|
107
|
+
));
|
|
108
|
+
const used = new Set(conflicts.map(({ resource_id: resourceId }) => resourceId));
|
|
109
|
+
const conflictResources = [...used]
|
|
110
|
+
.map((resourceId) => ({ resource_id: resourceId, ...resourceById.get(resourceId) }))
|
|
111
|
+
.sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
112
|
+
return { conflicts, conflictResources };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* witnessが宣言した境界pathを集める。
|
|
117
|
+
*
|
|
118
|
+
* 鮮度のdiff交差判定に使うので、observationへ効くpathを漏れなく採る——所有path、
|
|
119
|
+
* 書き込み、読み取り、affected test、sensor queryのexpect path。symbol owns自体は
|
|
120
|
+
* pathを持たないが、それを裏取りするqueryのexpect pathが同じ効果を持つ。
|
|
121
|
+
*/
|
|
122
|
+
function boundaryPathsOf(witness) {
|
|
123
|
+
const paths = new Set();
|
|
124
|
+
for (const own of witness.owns) {
|
|
125
|
+
if (own.kind === 'path') paths.add(own.target);
|
|
126
|
+
}
|
|
127
|
+
for (const path of witness.writes) paths.add(path);
|
|
128
|
+
for (const path of witness.reads) paths.add(path);
|
|
129
|
+
for (const path of witness.affected_tests) paths.add(path);
|
|
130
|
+
for (const entry of witness.sensor_provenance.queries) paths.add(entry.expect.path);
|
|
131
|
+
for (const line of witness.lines ?? []) {
|
|
132
|
+
for (const anchor of line.anchors) paths.add(anchor.path);
|
|
133
|
+
}
|
|
134
|
+
return [...paths].sort(compareText);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function precedencesFrom(verdicts) {
|
|
138
|
+
return verdicts
|
|
139
|
+
.filter((verdict) => verdict.type === 'precedence')
|
|
140
|
+
.map((verdict) => ({
|
|
141
|
+
from_task_id: verdict.from_todo_id,
|
|
142
|
+
to_task_id: verdict.to_todo_id,
|
|
143
|
+
reason: verdict.reason,
|
|
144
|
+
}))
|
|
145
|
+
.sort((left, right) => compareText(
|
|
146
|
+
`${left.from_task_id}\0${left.to_task_id}\0${left.reason}`,
|
|
147
|
+
`${right.from_task_id}\0${right.to_task_id}\0${right.reason}`,
|
|
148
|
+
));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function unknownsFrom(detail) {
|
|
152
|
+
const entries = [
|
|
153
|
+
...(Array.isArray(detail?.unknowns) ? detail.unknowns : []),
|
|
154
|
+
...(Array.isArray(detail?.unresolved_witnesses) ? detail.unresolved_witnesses : []),
|
|
155
|
+
]
|
|
156
|
+
.filter((entry) => entry !== null && typeof entry === 'object'
|
|
157
|
+
&& typeof entry.todo_id === 'string' && typeof entry.kind === 'string'
|
|
158
|
+
&& typeof entry.ref === 'string')
|
|
159
|
+
.map((entry) => ({
|
|
160
|
+
task_id: entry.todo_id,
|
|
161
|
+
kind: entry.kind,
|
|
162
|
+
ref: entry.ref,
|
|
163
|
+
}));
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
return entries
|
|
166
|
+
.filter((entry) => {
|
|
167
|
+
const key = `${entry.task_id}\0${entry.kind}\0${entry.ref}`;
|
|
168
|
+
if (seen.has(key)) return false;
|
|
169
|
+
seen.add(key);
|
|
170
|
+
return true;
|
|
171
|
+
})
|
|
172
|
+
.sort((left, right) => compareText(
|
|
173
|
+
`${left.task_id}\0${left.kind}\0${left.ref}`,
|
|
174
|
+
`${right.task_id}\0${right.kind}\0${right.ref}`,
|
|
175
|
+
));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* witness setとsensor evidenceから`lattice.todo_independence.v5`を作る。
|
|
180
|
+
*
|
|
181
|
+
* compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
|
|
182
|
+
* unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
|
|
183
|
+
* `dispatchable`ならpairwise verdictとwave planを写し、`BOUNDARY_UNKNOWN`なら
|
|
184
|
+
* unknownをtask単位で記録してwave planを持たない。それ以外のnon-dispatchableは
|
|
185
|
+
* 契約違反または観測不成立であり、artifactへ丸めずtyped errorで止める。
|
|
186
|
+
*/
|
|
187
|
+
/**
|
|
188
|
+
* 前回artifactと今回の宣言を突き合わせて、task別の膨張を事実だけ記録する(s1)。
|
|
189
|
+
*
|
|
190
|
+
* **判定しない。** 上流の契約確定に追従した膨張・自分の変更の後始末・元から在った面の見落とし・
|
|
191
|
+
* 思いつきで盛った分は、**機械には区別できない**(2026-08-09の実測: 1つのtaskへ前3種が同時に出た)。
|
|
192
|
+
* 装置が言えるのは「増えた」「何が増減した」「何回目か」「合流点か」までで、仕分けはAIがやる。
|
|
193
|
+
*
|
|
194
|
+
* 前回が無い(初回・plan version違い・legacy版)時は、比較相手なしとして今回の宣言を初回として置く。
|
|
195
|
+
* **「前回が読めなかった」を「膨張ゼロ」に化かさない**ため、compared_witness_digestで区別できる。
|
|
196
|
+
*/
|
|
197
|
+
function scopeExpansionFrom({ taskIds, boundaries, previousArtifact, plan }) {
|
|
198
|
+
const comparable = previousArtifact !== null && previousArtifact !== undefined
|
|
199
|
+
&& previousArtifact.schema === TODO_INDEPENDENCE_SCHEMA
|
|
200
|
+
&& previousArtifact.plan_key === plan.plan_key
|
|
201
|
+
&& previousArtifact.plan_version === plan.plan_version
|
|
202
|
+
&& previousArtifact.topology_digest === plan.topology_digest;
|
|
203
|
+
const previousBoundaries = new Map(comparable
|
|
204
|
+
? previousArtifact.task_boundaries.map((entry) => [entry.task_id, entry.paths])
|
|
205
|
+
: []);
|
|
206
|
+
const previousExpansion = new Map(comparable
|
|
207
|
+
? previousArtifact.scope_expanded.map((entry) => [entry.task_id, entry])
|
|
208
|
+
: []);
|
|
209
|
+
// 合流点(gate形)はplanのtopologyが持つ事実で、宣言とは独立に決まる。
|
|
210
|
+
const incoming = new Map(taskIds.map((taskId) => [taskId, 0]));
|
|
211
|
+
for (const edge of plan.hard_dependencies ?? []) {
|
|
212
|
+
const to = edge?.to?.task_id ?? edge?.to_task_id;
|
|
213
|
+
if (incoming.has(to)) incoming.set(to, incoming.get(to) + 1);
|
|
214
|
+
}
|
|
215
|
+
return taskIds.map((taskId) => {
|
|
216
|
+
const current = boundaries.get(taskId) ?? [];
|
|
217
|
+
const previous = previousBoundaries.get(taskId) ?? null;
|
|
218
|
+
const prior = previousExpansion.get(taskId) ?? null;
|
|
219
|
+
// **subset入替を「初回」へ偽装しない。** witnessはwave単位のsubsetで書き出せるので、
|
|
220
|
+
// A→B→Aと入れ替わると直前artifactにAが居ない。そこで今回を初回と置くと、
|
|
221
|
+
// **本当の初回宣言数と膨張回数が消える**(suzune の監査で実測・room [1152])。
|
|
222
|
+
// 比較相手はあるのにこのtaskが居ない時は、**数を主張せずnullで「分からない」と言う。**
|
|
223
|
+
if (comparable && previous === null) {
|
|
224
|
+
return {
|
|
225
|
+
task_id: taskId,
|
|
226
|
+
compared_witness_digest: previousArtifact.witness_set_digest,
|
|
227
|
+
first_seen_path_count: null,
|
|
228
|
+
path_count: current.length,
|
|
229
|
+
added_paths: [],
|
|
230
|
+
removed_paths: [],
|
|
231
|
+
growth_events: null,
|
|
232
|
+
gate_shape: (incoming.get(taskId) ?? 0) >= 2,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const currentSet = new Set(current);
|
|
236
|
+
const previousSet = new Set(previous ?? []);
|
|
237
|
+
const added = previous === null ? [] : current.filter((path) => !previousSet.has(path));
|
|
238
|
+
const removed = previous === null ? [] : previous.filter((path) => !currentSet.has(path));
|
|
239
|
+
return {
|
|
240
|
+
task_id: taskId,
|
|
241
|
+
compared_witness_digest: comparable ? previousArtifact.witness_set_digest : null,
|
|
242
|
+
// **unknown は後続へ伝播する。** 一度 subset gap で累計が分からなくなった task は、
|
|
243
|
+
// 次の compile で `(null ?? 0) + 1` のように既知へ戻してはいけない——
|
|
244
|
+
// **欠落前の累計が分からないことは、その後も分からないまま**である(suzune [1173])。
|
|
245
|
+
first_seen_path_count: prior === null
|
|
246
|
+
? current.length
|
|
247
|
+
: prior.first_seen_path_count,
|
|
248
|
+
path_count: current.length,
|
|
249
|
+
added_paths: [...added].sort(compareText),
|
|
250
|
+
removed_paths: [...removed].sort(compareText),
|
|
251
|
+
growth_events: prior !== null && prior.growth_events === null
|
|
252
|
+
? null
|
|
253
|
+
: (prior?.growth_events ?? 0) + (added.length > 0 ? 1 : 0),
|
|
254
|
+
gate_shape: (incoming.get(taskId) ?? 0) >= 2,
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function compileTodoIndependence(options = {}) {
|
|
260
|
+
const { witnessSet, plan, baseSha, compiledAt, sensorEvidence, previousArtifact = null } = options;
|
|
261
|
+
if (!validateTodoWitnessSet(witnessSet)) {
|
|
262
|
+
fail('INDEPENDENCE_WITNESS_INVALID', 'witness_set_invalid');
|
|
263
|
+
}
|
|
264
|
+
if (!isGitSha(baseSha)) fail('INDEPENDENCE_BASE_INVALID', 'base_sha_invalid');
|
|
265
|
+
if (witnessSet.project_id !== plan.project_id || witnessSet.plan_key !== plan.plan_key) {
|
|
266
|
+
fail('INDEPENDENCE_PLAN_MISMATCH', 'witness_set_plan_mismatch', {
|
|
267
|
+
witness_plan_key: witnessSet.plan_key, plan_key: plan.plan_key,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const planTaskIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
|
|
272
|
+
const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
|
|
273
|
+
const absent = taskIds.filter((taskId) => !planTaskIds.has(taskId));
|
|
274
|
+
if (absent.length > 0) {
|
|
275
|
+
fail('INDEPENDENCE_TASK_ABSENT', 'witness_task_absent_from_plan', { task_ids: absent });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const request = synthesizeWitnessRunRequest(witnessSet, {
|
|
279
|
+
baseSha, requestId: witnessRequestId(witnessSet, plan.plan_version),
|
|
280
|
+
});
|
|
281
|
+
const compiled = compileRuntimePlanV1({
|
|
282
|
+
request,
|
|
283
|
+
sensorEvidence,
|
|
284
|
+
planRef: witnessRequestId(witnessSet, plan.plan_version),
|
|
285
|
+
planEpoch: 0,
|
|
286
|
+
predecessorRefs: [],
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (compiled.outcome !== 'dispatchable'
|
|
290
|
+
&& !(compiled.outcome === 'non_dispatchable' && compiled.code === 'BOUNDARY_UNKNOWN')) {
|
|
291
|
+
fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_compile_not_dispatchable', {
|
|
292
|
+
code: compiled.code ?? null, compile_detail: compiled.detail ?? null,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const dispatchable = compiled.outcome === 'dispatchable';
|
|
297
|
+
const conflictProjection = dispatchable
|
|
298
|
+
? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
|
|
299
|
+
: { conflicts: [], conflictResources: [] };
|
|
300
|
+
const boundaryEntries = taskIds.map((taskId) => ({
|
|
301
|
+
task_id: taskId,
|
|
302
|
+
paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
|
|
303
|
+
}));
|
|
304
|
+
const artifact = {
|
|
305
|
+
schema: TODO_INDEPENDENCE_SCHEMA,
|
|
306
|
+
project_id: plan.project_id,
|
|
307
|
+
plan_key: plan.plan_key,
|
|
308
|
+
plan_version: plan.plan_version,
|
|
309
|
+
topology_digest: plan.topology_digest,
|
|
310
|
+
base_sha: baseSha,
|
|
311
|
+
witness_set_digest: witnessSet.witness_set_digest,
|
|
312
|
+
compiled_at: compiledAt,
|
|
313
|
+
task_ids: taskIds,
|
|
314
|
+
task_boundaries: boundaryEntries,
|
|
315
|
+
conflict_resources: conflictProjection.conflictResources,
|
|
316
|
+
conflicts: conflictProjection.conflicts,
|
|
317
|
+
precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
|
|
318
|
+
unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
|
|
319
|
+
wave_plan: dispatchable
|
|
320
|
+
? {
|
|
321
|
+
waves: compiled.schedule.waves.map((wave) => ({
|
|
322
|
+
task_ids: [...wave.todo_ids].sort(compareText),
|
|
323
|
+
})),
|
|
324
|
+
minimum_feasible_waves: compiled.schedule.minimum_feasible_waves,
|
|
325
|
+
}
|
|
326
|
+
: null,
|
|
327
|
+
scope_expanded: scopeExpansionFrom({
|
|
328
|
+
taskIds,
|
|
329
|
+
boundaries: new Map(boundaryEntries.map((entry) => [entry.task_id, entry.paths])),
|
|
330
|
+
previousArtifact,
|
|
331
|
+
plan,
|
|
332
|
+
}),
|
|
333
|
+
outcome: dispatchable ? 'compiled' : 'unknown',
|
|
334
|
+
result_digest: '',
|
|
335
|
+
};
|
|
336
|
+
artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
|
|
337
|
+
|
|
338
|
+
// unknownを1件も持たないのに非dispatchableという状態は、原因を失った成功扱いになる。
|
|
339
|
+
if (!dispatchable && artifact.unknowns.length === 0) {
|
|
340
|
+
fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_unknown_without_cause', {
|
|
341
|
+
compile_detail: compiled.detail ?? null,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (!validateTodoIndependence(artifact)) {
|
|
345
|
+
fail('INDEPENDENCE_ARTIFACT_INVALID', 'independence_artifact_invalid');
|
|
346
|
+
}
|
|
347
|
+
return artifact;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function pairKey(left, right) {
|
|
351
|
+
return left < right ? `${left}\0${right}` : `${right}\0${left}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 変更pathが宣言境界に触れたかを判定する。
|
|
356
|
+
*
|
|
357
|
+
* 完全一致か、宣言側が`dir/`形式のprefixで変更pathを覆う場合に交差とみなす
|
|
358
|
+
* (runtime diff observerのscope判定と同一規則)。
|
|
359
|
+
*/
|
|
360
|
+
function boundaryTouches(declaredPaths, changedPath) {
|
|
361
|
+
return declaredPaths.some((declared) => declared === changedPath
|
|
362
|
+
|| (declared.endsWith('/') && changedPath.startsWith(declared)));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* ready frontierを、記録済みartifactだけを根拠に
|
|
367
|
+
* 「検証済み独立」「直列化すべき組」「進行中との競合」「未検査」へ分けて投影する。
|
|
368
|
+
*
|
|
369
|
+
* sensorは引かない。artifactが無い/planが進んだ場合はcoverageで示す。
|
|
370
|
+
* HEADが動いた場合は`changedPaths`と宣言境界の交差を見て、交差したtaskだけを未検査へ落とす
|
|
371
|
+
* (ADR 0128 Decision 4)。宣言境界に触れないdiffは観測を変えないため、
|
|
372
|
+
* 交差しなかったtaskのverified独立は維持する。
|
|
373
|
+
*
|
|
374
|
+
* @param {object} options
|
|
375
|
+
* @param {object|null} options.artifact 記録済みindependence artifact(v2)
|
|
376
|
+
* @param {string[]} options.readyTaskIds 同一plan内のready task
|
|
377
|
+
* @param {string[]} options.activeTaskIds 同一plan内のin-progress task
|
|
378
|
+
* @param {object} options.plan plan_versionとtopology_digestを持つactive plan
|
|
379
|
+
* @param {string} options.currentBaseSha 現在のHEAD
|
|
380
|
+
* @param {string[]|null} options.changedPaths base_sha..HEADの変更path。
|
|
381
|
+
* nullはdiffを確定できなかったこと(base到達不能)を意味し、全taskを交差扱いにする。
|
|
382
|
+
*/
|
|
383
|
+
export function projectIndependenceFrontier({
|
|
384
|
+
artifact, readyTaskIds, activeTaskIds = [], plan, currentBaseSha, changedPaths = null,
|
|
385
|
+
}) {
|
|
386
|
+
const ready = [...readyTaskIds].sort(compareText);
|
|
387
|
+
const active = [...activeTaskIds].sort(compareText);
|
|
388
|
+
if (!isGitSha(currentBaseSha)) fail('INDEPENDENCE_BASE_INVALID', 'current_base_sha_invalid');
|
|
389
|
+
|
|
390
|
+
const emptyFrontier = () => ({
|
|
391
|
+
parallel_groups: [],
|
|
392
|
+
serialize_pairs: [],
|
|
393
|
+
conflicts_with_active: [],
|
|
394
|
+
unknown: ready.map((taskId) => ({
|
|
395
|
+
task_id: taskId,
|
|
396
|
+
unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
|
|
397
|
+
})),
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
if (artifact === null) {
|
|
401
|
+
return {
|
|
402
|
+
coverage: 'missing',
|
|
403
|
+
drift: null,
|
|
404
|
+
active_task_ids: active,
|
|
405
|
+
uncovered_active_task_ids: active,
|
|
406
|
+
frontier: emptyFrontier(),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (isTodoIndependenceLegacyMarker(artifact)) {
|
|
411
|
+
return {
|
|
412
|
+
coverage: 'superseded',
|
|
413
|
+
drift: null,
|
|
414
|
+
active_task_ids: active,
|
|
415
|
+
uncovered_active_task_ids: active,
|
|
416
|
+
frontier: {
|
|
417
|
+
parallel_groups: [],
|
|
418
|
+
serialize_pairs: [],
|
|
419
|
+
conflicts_with_active: [],
|
|
420
|
+
unknown: ready.map((taskId) => ({
|
|
421
|
+
task_id: taskId,
|
|
422
|
+
unknowns: [{ kind: 'record_superseded', ref: artifact.legacy_schema }],
|
|
423
|
+
})),
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const superseded = artifact.plan_version !== plan.plan_version
|
|
429
|
+
|| artifact.topology_digest !== plan.topology_digest;
|
|
430
|
+
const coverage = superseded ? 'superseded'
|
|
431
|
+
: artifact.base_sha === currentBaseSha ? 'verified' : 'stale';
|
|
432
|
+
|
|
433
|
+
const covered = new Set(artifact.task_ids);
|
|
434
|
+
const boundariesByTask = new Map(artifact.task_boundaries
|
|
435
|
+
.map((entry) => [entry.task_id, entry.paths]));
|
|
436
|
+
|
|
437
|
+
// 宣言境界とdiffの交差。coverage==='stale'のときだけ意味を持つ。
|
|
438
|
+
let drift = null;
|
|
439
|
+
const intersecting = new Set();
|
|
440
|
+
if (coverage === 'stale') {
|
|
441
|
+
const baseReachable = changedPaths !== null;
|
|
442
|
+
if (!baseReachable) {
|
|
443
|
+
// 差分を確定できないなら、記録が今も有効だと主張する根拠が無い。
|
|
444
|
+
for (const taskId of artifact.task_ids) intersecting.add(taskId);
|
|
445
|
+
} else {
|
|
446
|
+
for (const taskId of artifact.task_ids) {
|
|
447
|
+
const declared = boundariesByTask.get(taskId) ?? [];
|
|
448
|
+
if (changedPaths.some((changed) => boundaryTouches(declared, changed))) {
|
|
449
|
+
intersecting.add(taskId);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
drift = {
|
|
454
|
+
base_reachable: baseReachable,
|
|
455
|
+
changed_path_count: baseReachable ? changedPaths.length : 0,
|
|
456
|
+
intersecting_task_ids: [...intersecting].sort(compareText),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const unknownByTask = new Map();
|
|
461
|
+
const noteUnknown = (taskId, kind, ref) => {
|
|
462
|
+
if (!unknownByTask.has(taskId)) unknownByTask.set(taskId, []);
|
|
463
|
+
unknownByTask.get(taskId).push({ kind, ref });
|
|
464
|
+
};
|
|
465
|
+
for (const taskId of ready) {
|
|
466
|
+
if (!covered.has(taskId)) noteUnknown(taskId, 'witness_missing', 'task_not_in_witness_set');
|
|
467
|
+
}
|
|
468
|
+
for (const entry of artifact.unknowns) {
|
|
469
|
+
if (ready.includes(entry.task_id)) noteUnknown(entry.task_id, entry.kind, entry.ref);
|
|
470
|
+
}
|
|
471
|
+
// 記録全体がverdictを持たない場合、どのpairについても判定は存在しない。
|
|
472
|
+
//
|
|
473
|
+
// compileがBOUNDARY_UNKNOWNで止まると、front endはpairwise verdictを1つも返さないので
|
|
474
|
+
// artifact.conflictsは空になる。この空をそのまま読むと、自分にunknownが無いreadyどうしが
|
|
475
|
+
// 「ぶつかる記録が無い=独立」として並列グループへ入る。実際には**誰についても判定していない**。
|
|
476
|
+
// 新規fileを1つ宣言したToDoが混ざるだけで、無関係なToDoの実competitionが消えて
|
|
477
|
+
// 検証済み並列に見える——不在を証拠に読み替える、最も危険な向きの誤りである。
|
|
478
|
+
if (artifact.outcome !== 'compiled') {
|
|
479
|
+
for (const taskId of ready) {
|
|
480
|
+
if (covered.has(taskId)) noteUnknown(taskId, 'plan_verdicts_absent', artifact.outcome);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// planが進んだ記録はtask単位に救えない(topology自体が別物)。
|
|
484
|
+
// HEADだけが進んだ場合は、宣言境界に触れたtaskだけを落とす。
|
|
485
|
+
if (coverage === 'superseded') {
|
|
486
|
+
for (const taskId of ready) {
|
|
487
|
+
if (covered.has(taskId)) noteUnknown(taskId, 'record_superseded', artifact.base_sha);
|
|
488
|
+
}
|
|
489
|
+
} else if (coverage === 'stale') {
|
|
490
|
+
for (const taskId of ready) {
|
|
491
|
+
if (covered.has(taskId) && intersecting.has(taskId)) {
|
|
492
|
+
noteUnknown(taskId, 'record_stale', artifact.base_sha);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 記録が今のtaskへ有効か。無効な相手との競合は「競合なし」でなく「判定不能」であり、
|
|
498
|
+
// conflicts_with_activeではなくuncovered_activeとして示す。
|
|
499
|
+
const usable = (taskId) => covered.has(taskId)
|
|
500
|
+
&& !(coverage === 'superseded')
|
|
501
|
+
&& !(coverage === 'stale' && intersecting.has(taskId));
|
|
502
|
+
const uncoveredActive = active.filter((taskId) => !usable(taskId));
|
|
503
|
+
|
|
504
|
+
const readySet = new Set(ready);
|
|
505
|
+
const activeSet = new Set(active);
|
|
506
|
+
const serializePairs = [];
|
|
507
|
+
const activeConflicts = [];
|
|
508
|
+
const blocked = new Set();
|
|
509
|
+
|
|
510
|
+
const notePair = (left, right, type, detail, kind) => {
|
|
511
|
+
const severability = type === 'conflict' ? severabilityOfConflictKind(kind) : 'serial';
|
|
512
|
+
const pairKind = type === 'conflict' ? kind : null;
|
|
513
|
+
if (readySet.has(left) && readySet.has(right)) {
|
|
514
|
+
const pair = [left, right].sort(compareText);
|
|
515
|
+
serializePairs.push({
|
|
516
|
+
task_ids: pair, type, detail, kind: pairKind, severability,
|
|
517
|
+
});
|
|
518
|
+
blocked.add(pairKey(left, right));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
// 片端がactiveなら着手時の警告面へ回す。v1はここで黙って捨てていた。
|
|
522
|
+
const readyId = readySet.has(left) ? left : readySet.has(right) ? right : null;
|
|
523
|
+
const activeId = activeSet.has(left) ? left : activeSet.has(right) ? right : null;
|
|
524
|
+
if (readyId === null || activeId === null || readyId === activeId) return;
|
|
525
|
+
if (!usable(readyId) || !usable(activeId)) return;
|
|
526
|
+
activeConflicts.push({
|
|
527
|
+
ready_task_id: readyId, active_task_id: activeId, type, detail,
|
|
528
|
+
kind: pairKind, severability,
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const conflictResourceById = new Map(artifact.conflict_resources
|
|
533
|
+
.map((resource) => [resource.resource_id, resource]));
|
|
534
|
+
for (const conflict of artifact.conflicts) {
|
|
535
|
+
const resource = conflictResourceById.get(conflict.resource_id);
|
|
536
|
+
if (resource === undefined) {
|
|
537
|
+
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
538
|
+
resource_id: conflict.resource_id, observed_kind: null,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
|
|
542
|
+
resource.kind);
|
|
543
|
+
}
|
|
544
|
+
for (const precedence of artifact.precedences) {
|
|
545
|
+
notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
|
|
546
|
+
}
|
|
547
|
+
serializePairs.sort((left, right) => compareText(
|
|
548
|
+
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.type}\0${left.detail}`,
|
|
549
|
+
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.type}\0${right.detail}`,
|
|
550
|
+
));
|
|
551
|
+
activeConflicts.sort((left, right) => compareText(
|
|
552
|
+
`${left.ready_task_id}\0${left.active_task_id}\0${left.type}\0${left.detail}`,
|
|
553
|
+
`${right.ready_task_id}\0${right.active_task_id}\0${right.type}\0${right.detail}`,
|
|
554
|
+
));
|
|
555
|
+
|
|
556
|
+
// 未検査を並列グループへ入れない。verdictの不在を独立の証拠にできるのは、
|
|
557
|
+
// 両taskがcompile対象であり、どちらにもunknownが無いときだけである。
|
|
558
|
+
const eligible = ready.filter((taskId) => !unknownByTask.has(taskId));
|
|
559
|
+
const groups = [];
|
|
560
|
+
for (const taskId of eligible) {
|
|
561
|
+
const target = groups.find((group) => group.every((member) => !blocked.has(pairKey(member, taskId))));
|
|
562
|
+
if (target === undefined) groups.push([taskId]); else target.push(taskId);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
coverage,
|
|
567
|
+
drift,
|
|
568
|
+
active_task_ids: active,
|
|
569
|
+
uncovered_active_task_ids: uncoveredActive,
|
|
570
|
+
frontier: {
|
|
571
|
+
parallel_groups: groups.map((task_ids) => ({ task_ids: [...task_ids].sort(compareText) }))
|
|
572
|
+
.sort((left, right) => compareText(left.task_ids[0], right.task_ids[0])),
|
|
573
|
+
serialize_pairs: serializePairs,
|
|
574
|
+
conflicts_with_active: activeConflicts,
|
|
575
|
+
unknown: [...unknownByTask.entries()]
|
|
576
|
+
.map(([taskId, unknowns]) => ({ task_id: taskId, unknowns }))
|
|
577
|
+
.sort((left, right) => compareText(left.task_id, right.task_id)),
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function isIndependenceIdentifier(value) {
|
|
583
|
+
return isTodoIdentifier(value);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* revisionのtask migrationでwitness宣言のtask_idを写す(ADR 0128 Decision 6)。
|
|
588
|
+
*
|
|
589
|
+
* 写像だけを担い、宣言内容が改訂後も意味的に妥当かは主張しない——それは機械には判定できない。
|
|
590
|
+
* 解決できないIDはfail closedにする。既に新IDになっている宣言はそのまま通す(冪等)。
|
|
591
|
+
*
|
|
592
|
+
* @param {object} options
|
|
593
|
+
* @param {object} options.witnessSet 現在の宣言
|
|
594
|
+
* @param {Array<{from_task_id: string, to_task_id: string}>} options.taskMigration
|
|
595
|
+
* @param {string[]} options.planTaskIds 移行後planのtask_id集合
|
|
596
|
+
*/
|
|
597
|
+
export function migrateWitnessSetTaskIds({ witnessSet, taskMigration, planTaskIds }) {
|
|
598
|
+
const mapping = new Map(taskMigration.map((entry) => [entry.from_task_id, entry.to_task_id]));
|
|
599
|
+
const current = new Set(planTaskIds);
|
|
600
|
+
const migrated = {};
|
|
601
|
+
const unresolved = [];
|
|
602
|
+
let migratedCount = 0;
|
|
603
|
+
let removedCount = 0;
|
|
604
|
+
let unchangedCount = 0;
|
|
605
|
+
|
|
606
|
+
for (const taskId of Object.keys(witnessSet.manual_witness).sort(compareText)) {
|
|
607
|
+
const witness = witnessSet.manual_witness[taskId];
|
|
608
|
+
if (mapping.has(taskId)) {
|
|
609
|
+
const target = mapping.get(taskId);
|
|
610
|
+
if (target === 'removed') { removedCount += 1; continue; }
|
|
611
|
+
migrated[target] = witness;
|
|
612
|
+
if (target === taskId) unchangedCount += 1; else migratedCount += 1;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
// 移行表に無くても、既に現planのtaskなら再実行とみなして通す。
|
|
616
|
+
if (current.has(taskId)) { migrated[taskId] = witness; unchangedCount += 1; continue; }
|
|
617
|
+
unresolved.push(taskId);
|
|
618
|
+
}
|
|
619
|
+
if (unresolved.length > 0) {
|
|
620
|
+
fail('WITNESS_MIGRATION_UNRESOLVED', 'witness_task_id_unresolved', {
|
|
621
|
+
task_ids: unresolved.sort(compareText),
|
|
622
|
+
next_action: 'update_witness_set_manually',
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
if (Object.keys(migrated).length === 0) {
|
|
626
|
+
fail('WITNESS_MIGRATION_EMPTY', 'witness_set_would_be_empty');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const next = { ...witnessSet, manual_witness: migrated, witness_set_digest: '' };
|
|
630
|
+
next.witness_set_digest = todoSelfDigest(next, 'witness_set_digest');
|
|
631
|
+
if (!validateTodoWitnessSet(next)) {
|
|
632
|
+
fail('INVALID_TODO_WITNESS_SET', 'migrated_witness_set_invalid');
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
witnessSet: next,
|
|
636
|
+
migrated_count: migratedCount,
|
|
637
|
+
removed_count: removedCount,
|
|
638
|
+
unchanged_count: unchangedCount,
|
|
639
|
+
};
|
|
640
|
+
}
|