@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/seam-proposal.mjs
CHANGED
|
@@ -1,2011 +1,2011 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
|
|
3
|
-
import { digestArtifact } from './artifact-contracts.mjs';
|
|
4
|
-
import { portableSensorOutcome } from './sensor-adapter.mjs';
|
|
5
|
-
import {
|
|
6
|
-
SEAM_PROPOSAL_SCHEMA,
|
|
7
|
-
deriveSeamProposalId,
|
|
8
|
-
validateSeamProposal,
|
|
9
|
-
} from './seam-proposal-contracts.mjs';
|
|
10
|
-
import {
|
|
11
|
-
synthesizeWitnessRunRequest,
|
|
12
|
-
validateTodoIndependence,
|
|
13
|
-
validateTodoWitnessSet,
|
|
14
|
-
} from './todo-independence-contracts.mjs';
|
|
15
|
-
import {
|
|
16
|
-
digestTodoArtifact,
|
|
17
|
-
todoSelfDigest,
|
|
18
|
-
validateTodoPlan,
|
|
19
|
-
} from './todo-contracts.mjs';
|
|
20
|
-
import {
|
|
21
|
-
selfDigest,
|
|
22
|
-
validateRunRequest,
|
|
23
|
-
} from './runtime-contracts.mjs';
|
|
24
|
-
|
|
25
|
-
const WITNESS_FIELDS = Object.freeze([
|
|
26
|
-
'owns',
|
|
27
|
-
'reads',
|
|
28
|
-
'writes',
|
|
29
|
-
'resources',
|
|
30
|
-
'state_effects',
|
|
31
|
-
'sensor_provenance',
|
|
32
|
-
'affected_tests',
|
|
33
|
-
'unknowns',
|
|
34
|
-
]);
|
|
35
|
-
const STATE_KIND_MAP = Object.freeze({
|
|
36
|
-
state: 'state',
|
|
37
|
-
schema: 'state',
|
|
38
|
-
invariant: 'state',
|
|
39
|
-
effect: 'effect',
|
|
40
|
-
external_effect: 'effect',
|
|
41
|
-
});
|
|
42
|
-
const STRUCTURE_OPERATIONS = new Set(['query', 'callers', 'callees', 'impact']);
|
|
43
|
-
const HYPOTHESIS_PROVENANCE = 'extraction_hypothesis';
|
|
44
|
-
|
|
45
|
-
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
46
|
-
|
|
47
|
-
function fail(reason) {
|
|
48
|
-
throw new TypeError(`seam proposal producer契約違反: ${reason}`);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function plainRecord(value) {
|
|
52
|
-
return value !== null
|
|
53
|
-
&& typeof value === 'object'
|
|
54
|
-
&& !Array.isArray(value)
|
|
55
|
-
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function exactRecord(value, keys) {
|
|
59
|
-
if (!plainRecord(value)) return false;
|
|
60
|
-
const actual = Object.keys(value).sort(compareText);
|
|
61
|
-
const expected = [...keys].sort(compareText);
|
|
62
|
-
return actual.length === expected.length
|
|
63
|
-
&& actual.every((key, index) => key === expected[index]);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function sha16(value) {
|
|
67
|
-
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function resourceKey(value) {
|
|
71
|
-
return `${value.kind}\0${value.target}`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function surfaceKey(value) {
|
|
75
|
-
return `${value.kind}\0${value.target}\0${value.path}\0${value.role}`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function sortedUnique(values) {
|
|
79
|
-
return [...new Set(values)].sort(compareText);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function isSubset(left, right) {
|
|
83
|
-
return [...left].every((value) => right.has(value));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function pathPrefixOverlap(left, right) {
|
|
87
|
-
const leftIsPrefix = left.endsWith('/');
|
|
88
|
-
const rightIsPrefix = right.endsWith('/');
|
|
89
|
-
if (left === right) return true;
|
|
90
|
-
if (leftIsPrefix && (right === left.slice(0, -1) || right.startsWith(left))) return true;
|
|
91
|
-
if (rightIsPrefix && (left === right.slice(0, -1) || left.startsWith(right))) return true;
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function withWitness(request, manualWitness) {
|
|
96
|
-
const virtualRequest = structuredClone(request);
|
|
97
|
-
virtualRequest.manual_witness = structuredClone(manualWitness);
|
|
98
|
-
virtualRequest.request_digest = '';
|
|
99
|
-
virtualRequest.request_digest = selfDigest(virtualRequest, 'request_digest');
|
|
100
|
-
if (!validateRunRequest(virtualRequest)) fail('virtual witnessがrun_request.v1を満たさない');
|
|
101
|
-
return virtualRequest;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Clone the original witness and apply a closed, full-field ownership diff.
|
|
106
|
-
* A task patch containing only `owns` is deliberately rejected.
|
|
107
|
-
*/
|
|
108
|
-
export function buildVirtualWitness({ request, ownershipDiff } = {}) {
|
|
109
|
-
if (!validateRunRequest(request)) fail('requestがrun_request.v1を満たさない');
|
|
110
|
-
if (!Array.isArray(ownershipDiff)) fail('ownershipDiffがarrayではない');
|
|
111
|
-
const taskIds = new Set(request.todos.map(({ todo_id: todoId }) => todoId));
|
|
112
|
-
const seen = new Set();
|
|
113
|
-
const virtualWitness = structuredClone(request.manual_witness);
|
|
114
|
-
for (const patch of ownershipDiff) {
|
|
115
|
-
if (!exactRecord(patch, ['todo_id', ...WITNESS_FIELDS])
|
|
116
|
-
|| !taskIds.has(patch.todo_id)
|
|
117
|
-
|| seen.has(patch.todo_id)) {
|
|
118
|
-
fail('ownershipDiff entryがclosed full-field shapeではない');
|
|
119
|
-
}
|
|
120
|
-
seen.add(patch.todo_id);
|
|
121
|
-
virtualWitness[patch.todo_id] = Object.fromEntries(
|
|
122
|
-
WITNESS_FIELDS.map((field) => [field, structuredClone(patch[field])]),
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
withWitness(request, virtualWitness);
|
|
126
|
-
return virtualWitness;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function normalizeQueries(request) {
|
|
130
|
-
const byId = new Map();
|
|
131
|
-
for (const query of request.sensor_query_set.queries) {
|
|
132
|
-
if (byId.has(query.id)) fail(`query idが重複している: ${query.id}`);
|
|
133
|
-
byId.set(query.id, query);
|
|
134
|
-
}
|
|
135
|
-
return byId;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function normalizeEvidence(request, sensorEvidence) {
|
|
139
|
-
if (!exactRecord(sensorEvidence, ['outcomes'])
|
|
140
|
-
|| !Array.isArray(sensorEvidence.outcomes)
|
|
141
|
-
|| sensorEvidence.outcomes.length !== request.sensor_query_set.queries.length) {
|
|
142
|
-
fail('sensorEvidenceがquery setとexact整合しない');
|
|
143
|
-
}
|
|
144
|
-
const byId = new Map();
|
|
145
|
-
request.sensor_query_set.queries.forEach((query, index) => {
|
|
146
|
-
const outcome = sensorEvidence.outcomes[index];
|
|
147
|
-
if (!exactRecord(outcome, ['query_id', 'operation', 'status', 'raw'])
|
|
148
|
-
|| outcome.query_id !== query.id
|
|
149
|
-
|| outcome.operation !== query.operation
|
|
150
|
-
|| typeof outcome.status !== 'string'
|
|
151
|
-
|| outcome.status.length === 0) {
|
|
152
|
-
fail(`sensorEvidence outcomeがquery ${query.id}とexact整合しない`);
|
|
153
|
-
}
|
|
154
|
-
const portable = portableSensorOutcome(outcome.raw);
|
|
155
|
-
byId.set(query.id, {
|
|
156
|
-
...outcome,
|
|
157
|
-
portable_digest: digestArtifact({
|
|
158
|
-
query_id: outcome.query_id,
|
|
159
|
-
operation: outcome.operation,
|
|
160
|
-
status: outcome.status,
|
|
161
|
-
portable,
|
|
162
|
-
}),
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
return byId;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function bindingMatchesQuery(binding, query) {
|
|
169
|
-
if (query === undefined) return false;
|
|
170
|
-
const { expect } = binding;
|
|
171
|
-
if (expect.kind === 'affected') {
|
|
172
|
-
return query.operation === 'affected' && query.target === expect.path;
|
|
173
|
-
}
|
|
174
|
-
if (expect.kind === 'symbol') {
|
|
175
|
-
return STRUCTURE_OPERATIONS.has(query.operation) && query.target === expect.name;
|
|
176
|
-
}
|
|
177
|
-
return (STRUCTURE_OPERATIONS.has(query.operation) || query.operation === 'affected')
|
|
178
|
-
&& query.target === expect.path;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function rawPayload(raw) {
|
|
182
|
-
return plainRecord(raw) && Object.hasOwn(raw, 'data') ? raw.data : raw;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function affectedPayload(raw, expectPath) {
|
|
186
|
-
if (plainRecord(raw) && Array.isArray(raw.targets)) {
|
|
187
|
-
const entry = raw.targets.find((candidate) => (
|
|
188
|
-
plainRecord(candidate) && candidate.target === expectPath
|
|
189
|
-
));
|
|
190
|
-
return plainRecord(entry) && plainRecord(entry.data) ? entry.data : null;
|
|
191
|
-
}
|
|
192
|
-
const payload = rawPayload(raw);
|
|
193
|
-
return plainRecord(payload) ? payload : null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function affectedTarget(raw, expectPath) {
|
|
197
|
-
if (!plainRecord(raw) || !Array.isArray(raw.targets)) return null;
|
|
198
|
-
const entry = raw.targets.find((candidate) => (
|
|
199
|
-
plainRecord(candidate) && candidate.target === expectPath
|
|
200
|
-
));
|
|
201
|
-
return plainRecord(entry) ? entry : null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function entryNode(entry) {
|
|
205
|
-
if (plainRecord(entry) && plainRecord(entry.node)) return entry.node;
|
|
206
|
-
return plainRecord(entry) ? entry : null;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function resolveBindingStatus(binding, outcome) {
|
|
210
|
-
if (outcome.status !== 'ready') return outcome.status;
|
|
211
|
-
const { expect } = binding;
|
|
212
|
-
if (expect.kind === 'affected') {
|
|
213
|
-
if (affectedTarget(outcome.raw, expect.path)?.path_state === 'absent') return 'path_absent';
|
|
214
|
-
const payload = affectedPayload(outcome.raw, expect.path);
|
|
215
|
-
if (payload === null
|
|
216
|
-
|| !Array.isArray(payload.changedFiles)
|
|
217
|
-
|| payload.changedFiles.length !== 1
|
|
218
|
-
|| payload.changedFiles[0] !== expect.path) {
|
|
219
|
-
return 'empty';
|
|
220
|
-
}
|
|
221
|
-
return 'ready';
|
|
222
|
-
}
|
|
223
|
-
const payload = rawPayload(outcome.raw);
|
|
224
|
-
if (!Array.isArray(payload)) return 'invalid_json';
|
|
225
|
-
if (expect.kind === 'symbol') {
|
|
226
|
-
const matches = payload.filter((entry) => {
|
|
227
|
-
const node = entryNode(entry);
|
|
228
|
-
return node !== null && node.name === expect.name && node.filePath === expect.path;
|
|
229
|
-
});
|
|
230
|
-
if (matches.length === 1) return 'ready';
|
|
231
|
-
return matches.length === 0 ? 'symbol_absent' : 'unresolved';
|
|
232
|
-
}
|
|
233
|
-
const matches = payload.filter((entry) => entryNode(entry)?.filePath === expect.path);
|
|
234
|
-
return matches.length >= 1 ? 'ready' : 'empty';
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function bindingCoversOwn(binding, own) {
|
|
238
|
-
if (own.kind === 'symbol') {
|
|
239
|
-
return binding.expect.kind === 'symbol' && binding.expect.name === own.target;
|
|
240
|
-
}
|
|
241
|
-
return (binding.expect.kind === 'path' || binding.expect.kind === 'affected')
|
|
242
|
-
&& binding.expect.path === own.target;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function normalizeHypotheses(surfaceHypotheses, manualWitness) {
|
|
246
|
-
if (!Array.isArray(surfaceHypotheses)) fail('surfaceHypothesesがarrayではない');
|
|
247
|
-
const taskIds = new Set(Object.keys(manualWitness));
|
|
248
|
-
const hypotheses = surfaceHypotheses.map((entry) => {
|
|
249
|
-
if (!exactRecord(entry, [
|
|
250
|
-
'kind', 'target', 'path', 'owner_task_id', 'affected_tests', 'provenance',
|
|
251
|
-
])
|
|
252
|
-
|| !['symbol', 'path'].includes(entry.kind)
|
|
253
|
-
|| typeof entry.target !== 'string'
|
|
254
|
-
|| typeof entry.path !== 'string'
|
|
255
|
-
|| !taskIds.has(entry.owner_task_id)
|
|
256
|
-
|| !Array.isArray(entry.affected_tests)
|
|
257
|
-
|| entry.provenance !== HYPOTHESIS_PROVENANCE) {
|
|
258
|
-
fail('surface hypothesis shapeが不正');
|
|
259
|
-
}
|
|
260
|
-
if (entry.kind === 'path' && entry.target !== entry.path) {
|
|
261
|
-
fail('path hypothesisのtargetとpathが一致しない');
|
|
262
|
-
}
|
|
263
|
-
const witness = manualWitness[entry.owner_task_id];
|
|
264
|
-
if (!witness.owns.some((own) => resourceKey(own) === `${entry.kind}\0${entry.target}`)
|
|
265
|
-
|| !witness.writes.includes(entry.path)
|
|
266
|
-
|| entry.affected_tests.some((path) => !witness.affected_tests.includes(path))) {
|
|
267
|
-
fail('surface hypothesisが完全なvirtual witnessへ反映されていない');
|
|
268
|
-
}
|
|
269
|
-
return {
|
|
270
|
-
...structuredClone(entry),
|
|
271
|
-
affected_tests: [...entry.affected_tests].sort(compareText),
|
|
272
|
-
};
|
|
273
|
-
}).sort((left, right) => compareText(
|
|
274
|
-
`${left.owner_task_id}\0${left.kind}\0${left.target}`,
|
|
275
|
-
`${right.owner_task_id}\0${right.kind}\0${right.target}`,
|
|
276
|
-
));
|
|
277
|
-
const keys = hypotheses.map((entry) => (
|
|
278
|
-
`${entry.owner_task_id}\0${entry.kind}\0${entry.target}`
|
|
279
|
-
));
|
|
280
|
-
if (new Set(keys).size !== keys.length) fail('surface hypothesisが重複している');
|
|
281
|
-
return hypotheses;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function hypothesisForOwn(hypotheses, todoId, own) {
|
|
285
|
-
return hypotheses.find((entry) => (
|
|
286
|
-
entry.owner_task_id === todoId
|
|
287
|
-
&& entry.kind === own.kind
|
|
288
|
-
&& entry.target === own.target
|
|
289
|
-
));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function hypothesisForBinding(hypotheses, todoId, binding) {
|
|
293
|
-
return hypotheses.find((entry) => (
|
|
294
|
-
entry.owner_task_id === todoId
|
|
295
|
-
&& ((binding.expect.kind === 'symbol'
|
|
296
|
-
&& entry.kind === 'symbol'
|
|
297
|
-
&& entry.target === binding.expect.name
|
|
298
|
-
&& entry.path === binding.expect.path)
|
|
299
|
-
|| (['path', 'affected'].includes(binding.expect.kind)
|
|
300
|
-
&& entry.kind === 'path'
|
|
301
|
-
&& entry.path === binding.expect.path))
|
|
302
|
-
));
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function manualProvenance(request) {
|
|
306
|
-
return {
|
|
307
|
-
source: 'manual_state_effect',
|
|
308
|
-
evidence_ref: `run-request:${request.request_id}`,
|
|
309
|
-
evidence_digest: request.request_digest,
|
|
310
|
-
status: 'asserted',
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function candidateProvenance(request) {
|
|
315
|
-
return {
|
|
316
|
-
source: 'manual_candidate_spec',
|
|
317
|
-
evidence_ref: `run-request:${request.request_id}`,
|
|
318
|
-
evidence_digest: request.request_digest,
|
|
319
|
-
status: 'asserted',
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function derivationResult({
|
|
324
|
-
outcome,
|
|
325
|
-
virtualWitness,
|
|
326
|
-
hypotheses,
|
|
327
|
-
resources = [],
|
|
328
|
-
conflicts = [],
|
|
329
|
-
unknowns = [],
|
|
330
|
-
unresolvedWitnesses = [],
|
|
331
|
-
drift = [],
|
|
332
|
-
}) {
|
|
333
|
-
const result = {
|
|
334
|
-
outcome,
|
|
335
|
-
resources,
|
|
336
|
-
conflicts,
|
|
337
|
-
unknowns,
|
|
338
|
-
unresolved_witnesses: unresolvedWitnesses,
|
|
339
|
-
drift,
|
|
340
|
-
};
|
|
341
|
-
return {
|
|
342
|
-
virtual_witness: virtualWitness,
|
|
343
|
-
surface_hypotheses: hypotheses,
|
|
344
|
-
...result,
|
|
345
|
-
input_digest: digestArtifact({
|
|
346
|
-
virtual_witness: virtualWitness,
|
|
347
|
-
surface_hypotheses: hypotheses,
|
|
348
|
-
}),
|
|
349
|
-
result_digest: digestArtifact(result),
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Pure duplicate of runtime-front-end's resource lowering. Existing surfaces retain real
|
|
355
|
-
* sensor provenance; absent/new surfaces can only be represented by an explicit extraction
|
|
356
|
-
* hypothesis and are never relabelled as observed sensor nodes.
|
|
357
|
-
*/
|
|
358
|
-
export function deriveVirtualBoundary({
|
|
359
|
-
request,
|
|
360
|
-
sensorEvidence,
|
|
361
|
-
virtualWitness = request?.manual_witness,
|
|
362
|
-
surfaceHypotheses = [],
|
|
363
|
-
} = {}) {
|
|
364
|
-
const virtualRequest = withWitness(request, virtualWitness);
|
|
365
|
-
const hypotheses = normalizeHypotheses(surfaceHypotheses, virtualRequest.manual_witness);
|
|
366
|
-
const queryById = normalizeQueries(virtualRequest);
|
|
367
|
-
const outcomeById = normalizeEvidence(virtualRequest, sensorEvidence);
|
|
368
|
-
const todoIds = virtualRequest.todos.map(({ todo_id: todoId }) => todoId);
|
|
369
|
-
const bindingsByTodo = new Map();
|
|
370
|
-
const drift = [];
|
|
371
|
-
|
|
372
|
-
for (const todoId of todoIds) {
|
|
373
|
-
const bindings = virtualRequest.manual_witness[todoId].sensor_provenance.queries;
|
|
374
|
-
bindingsByTodo.set(todoId, bindings);
|
|
375
|
-
for (const binding of bindings) {
|
|
376
|
-
if (!bindingMatchesQuery(binding, queryById.get(binding.query_id))) {
|
|
377
|
-
drift.push({
|
|
378
|
-
kind: 'query_drift',
|
|
379
|
-
todo_id: todoId,
|
|
380
|
-
ref: binding.query_id,
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
const statusQueries = [...queryById.values()]
|
|
386
|
-
.filter((query) => query.operation === 'status');
|
|
387
|
-
if (statusQueries.length !== 1) {
|
|
388
|
-
drift.push({ kind: 'query_drift', todo_id: todoIds[0], ref: 'status_query_count' });
|
|
389
|
-
}
|
|
390
|
-
if (drift.length > 0) {
|
|
391
|
-
return derivationResult({
|
|
392
|
-
outcome: 'query_drift',
|
|
393
|
-
virtualWitness: virtualRequest.manual_witness,
|
|
394
|
-
hypotheses,
|
|
395
|
-
drift: drift.sort((left, right) => compareText(
|
|
396
|
-
`${left.todo_id}\0${left.kind}\0${left.ref}`,
|
|
397
|
-
`${right.todo_id}\0${right.kind}\0${right.ref}`,
|
|
398
|
-
)),
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
const statusOutcome = outcomeById.get(statusQueries[0].id);
|
|
403
|
-
const unresolved = [];
|
|
404
|
-
if (statusOutcome.status !== 'ready') {
|
|
405
|
-
for (const todoId of todoIds) {
|
|
406
|
-
unresolved.push({ todo_id: todoId, kind: `sensor_${statusOutcome.status}`, ref: statusQueries[0].id });
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
for (const todoId of todoIds) {
|
|
411
|
-
for (const binding of bindingsByTodo.get(todoId)) {
|
|
412
|
-
const status = resolveBindingStatus(binding, outcomeById.get(binding.query_id));
|
|
413
|
-
const hypothesis = hypothesisForBinding(hypotheses, todoId, binding);
|
|
414
|
-
const hypothesisMayReplaceAbsence = hypothesis !== undefined
|
|
415
|
-
&& ['symbol_absent', 'path_absent', 'empty'].includes(status);
|
|
416
|
-
if (status !== 'ready' && !hypothesisMayReplaceAbsence) {
|
|
417
|
-
unresolved.push({ todo_id: todoId, kind: `sensor_${status}`, ref: binding.query_id });
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const affectedDrift = [];
|
|
423
|
-
for (const todoId of todoIds) {
|
|
424
|
-
const witness = virtualRequest.manual_witness[todoId];
|
|
425
|
-
for (const binding of bindingsByTodo.get(todoId)) {
|
|
426
|
-
if (binding.expect.kind !== 'affected') continue;
|
|
427
|
-
const outcome = outcomeById.get(binding.query_id);
|
|
428
|
-
if (resolveBindingStatus(binding, outcome) !== 'ready') continue;
|
|
429
|
-
const payload = affectedPayload(outcome.raw, binding.expect.path);
|
|
430
|
-
const observed = Array.isArray(payload?.affectedTests)
|
|
431
|
-
? [...payload.affectedTests].sort(compareText)
|
|
432
|
-
: null;
|
|
433
|
-
const declared = [...witness.affected_tests].sort(compareText);
|
|
434
|
-
if (observed === null
|
|
435
|
-
|| observed.length !== declared.length
|
|
436
|
-
|| observed.some((test, index) => test !== declared[index])) {
|
|
437
|
-
affectedDrift.push({
|
|
438
|
-
kind: 'affected_test_drift',
|
|
439
|
-
todo_id: todoId,
|
|
440
|
-
ref: binding.query_id,
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
if (affectedDrift.length > 0) {
|
|
446
|
-
return derivationResult({
|
|
447
|
-
outcome: 'affected_test_drift',
|
|
448
|
-
virtualWitness: virtualRequest.manual_witness,
|
|
449
|
-
hypotheses,
|
|
450
|
-
drift: affectedDrift.sort((left, right) => compareText(
|
|
451
|
-
`${left.todo_id}\0${left.ref}`, `${right.todo_id}\0${right.ref}`,
|
|
452
|
-
)),
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const ownGroups = new Map();
|
|
457
|
-
const coveringByTarget = new Map();
|
|
458
|
-
for (const todoId of todoIds) {
|
|
459
|
-
const witness = virtualRequest.manual_witness[todoId];
|
|
460
|
-
for (const own of witness.owns) {
|
|
461
|
-
const key = `${own.kind} ${own.target}`;
|
|
462
|
-
if (!ownGroups.has(key)) {
|
|
463
|
-
ownGroups.set(key, {
|
|
464
|
-
own,
|
|
465
|
-
todoIds: [],
|
|
466
|
-
hypotheticalTodoIds: new Set(),
|
|
467
|
-
});
|
|
468
|
-
}
|
|
469
|
-
const group = ownGroups.get(key);
|
|
470
|
-
group.todoIds.push(todoId);
|
|
471
|
-
const covering = bindingsByTodo.get(todoId).filter((binding) => bindingCoversOwn(binding, own));
|
|
472
|
-
for (const binding of covering) {
|
|
473
|
-
const seen = coveringByTarget.get(key);
|
|
474
|
-
if (seen === undefined) coveringByTarget.set(key, binding.query_id);
|
|
475
|
-
else if (seen !== binding.query_id) {
|
|
476
|
-
drift.push({ kind: 'query_drift', todo_id: todoId, ref: `${seen} ${binding.query_id}` });
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
const hypothesis = hypothesisForOwn(hypotheses, todoId, own);
|
|
480
|
-
if (hypothesis !== undefined) group.hypotheticalTodoIds.add(todoId);
|
|
481
|
-
if (covering.length === 0 && hypothesis === undefined) {
|
|
482
|
-
unresolved.push({ todo_id: todoId, kind: 'sensor_unbound', ref: `${own.kind}:${own.target}` });
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
if (drift.length > 0) {
|
|
487
|
-
return derivationResult({
|
|
488
|
-
outcome: 'query_drift',
|
|
489
|
-
virtualWitness: virtualRequest.manual_witness,
|
|
490
|
-
hypotheses,
|
|
491
|
-
drift,
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
for (let left = 0; left < todoIds.length; left += 1) {
|
|
496
|
-
for (let right = left + 1; right < todoIds.length; right += 1) {
|
|
497
|
-
const leftWitness = virtualRequest.manual_witness[todoIds[left]];
|
|
498
|
-
const rightWitness = virtualRequest.manual_witness[todoIds[right]];
|
|
499
|
-
const leftOwnPaths = new Set(leftWitness.owns
|
|
500
|
-
.filter((own) => own.kind === 'path').map((own) => own.target));
|
|
501
|
-
const rightOwnPaths = new Set(rightWitness.owns
|
|
502
|
-
.filter((own) => own.kind === 'path').map((own) => own.target));
|
|
503
|
-
for (const leftPath of leftWitness.writes) {
|
|
504
|
-
for (const rightPath of rightWitness.writes) {
|
|
505
|
-
if (!pathPrefixOverlap(leftPath, rightPath)) continue;
|
|
506
|
-
if (leftOwnPaths.has(leftPath) && rightOwnPaths.has(rightPath)
|
|
507
|
-
&& leftPath === rightPath) continue;
|
|
508
|
-
unresolved.push({
|
|
509
|
-
todo_id: todoIds[left],
|
|
510
|
-
kind: 'undeclared_write_overlap',
|
|
511
|
-
ref: `${leftPath} ${rightPath}`,
|
|
512
|
-
});
|
|
513
|
-
unresolved.push({
|
|
514
|
-
todo_id: todoIds[right],
|
|
515
|
-
kind: 'undeclared_write_overlap',
|
|
516
|
-
ref: `${leftPath} ${rightPath}`,
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
for (const todoId of todoIds) {
|
|
523
|
-
for (const unknown of virtualRequest.manual_witness[todoId].unknowns) {
|
|
524
|
-
unresolved.push({ todo_id: todoId, kind: unknown.kind, ref: unknown.ref });
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
const readWriteGroups = new Map();
|
|
529
|
-
for (let left = 0; left < todoIds.length; left += 1) {
|
|
530
|
-
for (let right = 0; right < todoIds.length; right += 1) {
|
|
531
|
-
if (left === right) continue;
|
|
532
|
-
const writer = virtualRequest.manual_witness[todoIds[left]];
|
|
533
|
-
const reader = virtualRequest.manual_witness[todoIds[right]];
|
|
534
|
-
for (const writePath of writer.writes) {
|
|
535
|
-
for (const readPath of reader.reads) {
|
|
536
|
-
if (!pathPrefixOverlap(writePath, readPath)) continue;
|
|
537
|
-
if (!readWriteGroups.has(writePath)) readWriteGroups.set(writePath, new Set());
|
|
538
|
-
readWriteGroups.get(writePath).add(todoIds[left]);
|
|
539
|
-
readWriteGroups.get(writePath).add(todoIds[right]);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const bareResourceGroups = new Map();
|
|
546
|
-
for (const todoId of todoIds) {
|
|
547
|
-
const witness = virtualRequest.manual_witness[todoId];
|
|
548
|
-
const stateIds = new Set(witness.state_effects.map(({ resource_id: id }) => id));
|
|
549
|
-
for (const resourceId of witness.resources) {
|
|
550
|
-
if (stateIds.has(resourceId)) continue;
|
|
551
|
-
if (!bareResourceGroups.has(resourceId)) bareResourceGroups.set(resourceId, new Set());
|
|
552
|
-
bareResourceGroups.get(resourceId).add(todoId);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
const resources = [];
|
|
557
|
-
for (const [key, group] of [...ownGroups.entries()].sort((left, right) => compareText(left[0], right[0]))) {
|
|
558
|
-
const coveringQueryId = coveringByTarget.get(key);
|
|
559
|
-
const observedTodoIds = new Set();
|
|
560
|
-
let outcome;
|
|
561
|
-
let representative;
|
|
562
|
-
if (coveringQueryId !== undefined) {
|
|
563
|
-
outcome = outcomeById.get(coveringQueryId);
|
|
564
|
-
for (const todoId of group.todoIds) {
|
|
565
|
-
const binding = bindingsByTodo.get(todoId).find((entry) => (
|
|
566
|
-
entry.query_id === coveringQueryId && bindingCoversOwn(entry, group.own)
|
|
567
|
-
));
|
|
568
|
-
if (representative === undefined) representative = binding;
|
|
569
|
-
if (binding !== undefined
|
|
570
|
-
&& statusOutcome.status === 'ready'
|
|
571
|
-
&& resolveBindingStatus(binding, outcome) === 'ready') {
|
|
572
|
-
observedTodoIds.add(todoId);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
const allObserved = group.todoIds.every((todoId) => observedTodoIds.has(todoId));
|
|
577
|
-
const allCovered = group.todoIds.every((todoId) => (
|
|
578
|
-
observedTodoIds.has(todoId) || group.hypotheticalTodoIds.has(todoId)
|
|
579
|
-
));
|
|
580
|
-
const hasHypothetical = group.todoIds.some(
|
|
581
|
-
(todoId) => group.hypotheticalTodoIds.has(todoId),
|
|
582
|
-
);
|
|
583
|
-
if (coveringQueryId === undefined && !allCovered) continue;
|
|
584
|
-
const status = allObserved ? 'observed'
|
|
585
|
-
: allCovered && hasHypothetical ? 'hypothetical' : 'unknown';
|
|
586
|
-
const provenance = status === 'hypothetical'
|
|
587
|
-
? [{
|
|
588
|
-
source: HYPOTHESIS_PROVENANCE,
|
|
589
|
-
evidence_ref: `virtual-witness:${virtualRequest.request_id}`,
|
|
590
|
-
evidence_digest: digestArtifact(hypotheses),
|
|
591
|
-
status: 'hypothetical',
|
|
592
|
-
}]
|
|
593
|
-
: [
|
|
594
|
-
candidateProvenance(virtualRequest),
|
|
595
|
-
{
|
|
596
|
-
source: 'sensor',
|
|
597
|
-
evidence_ref: coveringQueryId,
|
|
598
|
-
evidence_digest: outcome.portable_digest,
|
|
599
|
-
status: statusOutcome.status !== 'ready'
|
|
600
|
-
? statusOutcome.status
|
|
601
|
-
: resolveBindingStatus(representative, outcome),
|
|
602
|
-
},
|
|
603
|
-
];
|
|
604
|
-
resources.push({
|
|
605
|
-
resource_id: `own-${group.own.kind}-${sha16(group.own.target)}`,
|
|
606
|
-
kind: group.own.kind,
|
|
607
|
-
target: group.own.target,
|
|
608
|
-
todo_ids: [...group.todoIds].sort(compareText),
|
|
609
|
-
provenance,
|
|
610
|
-
status,
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
const stateGroups = new Map();
|
|
615
|
-
for (const todoId of todoIds) {
|
|
616
|
-
for (const entry of virtualRequest.manual_witness[todoId].state_effects) {
|
|
617
|
-
const kind = STATE_KIND_MAP[entry.kind];
|
|
618
|
-
if (!stateGroups.has(entry.resource_id)) {
|
|
619
|
-
stateGroups.set(entry.resource_id, { kind, todoIds: new Set() });
|
|
620
|
-
}
|
|
621
|
-
const group = stateGroups.get(entry.resource_id);
|
|
622
|
-
if (group.kind !== kind) fail(`resource ${entry.resource_id}のstate/effect kindが矛盾している`);
|
|
623
|
-
group.todoIds.add(todoId);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
for (const [resourceId, todos] of bareResourceGroups) {
|
|
627
|
-
if (stateGroups.has(resourceId)) {
|
|
628
|
-
for (const todoId of todos) stateGroups.get(resourceId).todoIds.add(todoId);
|
|
629
|
-
} else {
|
|
630
|
-
stateGroups.set(resourceId, { kind: 'state', todoIds: todos });
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
for (const [resourceId, group] of [...stateGroups.entries()]
|
|
634
|
-
.sort((left, right) => compareText(left[0], right[0]))) {
|
|
635
|
-
resources.push({
|
|
636
|
-
resource_id: resourceId,
|
|
637
|
-
kind: group.kind,
|
|
638
|
-
target: resourceId,
|
|
639
|
-
todo_ids: [...group.todoIds].sort(compareText),
|
|
640
|
-
provenance: [manualProvenance(virtualRequest)],
|
|
641
|
-
status: 'observed',
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
for (const [writePath, todos] of [...readWriteGroups.entries()]
|
|
645
|
-
.sort((left, right) => compareText(left[0], right[0]))) {
|
|
646
|
-
resources.push({
|
|
647
|
-
resource_id: `rw-${sha16(writePath)}`,
|
|
648
|
-
kind: 'state',
|
|
649
|
-
target: writePath,
|
|
650
|
-
todo_ids: [...todos].sort(compareText),
|
|
651
|
-
provenance: [manualProvenance(virtualRequest)],
|
|
652
|
-
status: 'observed',
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
let dynamicIndex = 0;
|
|
657
|
-
for (const unknown of unresolved) {
|
|
658
|
-
resources.push({
|
|
659
|
-
resource_id: `dyn-${String(dynamicIndex += 1).padStart(3, '0')}-${sha16(`${unknown.kind}:${unknown.ref}`)}`,
|
|
660
|
-
kind: 'dynamic',
|
|
661
|
-
target: `${unknown.kind}:${unknown.ref}`.slice(0, 4_096).trim(),
|
|
662
|
-
todo_ids: [unknown.todo_id],
|
|
663
|
-
provenance: [manualProvenance(virtualRequest)],
|
|
664
|
-
status: 'unknown',
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
resources.sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
668
|
-
if (new Set(resources.map(({ resource_id: id }) => id)).size !== resources.length) {
|
|
669
|
-
fail('導出resource_idが重複している');
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const conflicts = [];
|
|
673
|
-
const unknowns = [];
|
|
674
|
-
for (const resource of resources) {
|
|
675
|
-
if (resource.status === 'unknown') {
|
|
676
|
-
for (const todoId of resource.todo_ids) {
|
|
677
|
-
unknowns.push({
|
|
678
|
-
todo_id: todoId,
|
|
679
|
-
kind: resource.kind === 'dynamic'
|
|
680
|
-
? 'dynamic' : `sensor_${resource.provenance.find(({ source }) => source === 'sensor').status}`,
|
|
681
|
-
reason: `resource ${resource.resource_id} is ${
|
|
682
|
-
resource.kind === 'dynamic'
|
|
683
|
-
? 'dynamic'
|
|
684
|
-
: resource.provenance.find(({ source }) => source === 'sensor').status
|
|
685
|
-
}`,
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
continue;
|
|
689
|
-
}
|
|
690
|
-
for (let left = 0; left < resource.todo_ids.length; left += 1) {
|
|
691
|
-
for (let right = left + 1; right < resource.todo_ids.length; right += 1) {
|
|
692
|
-
conflicts.push({
|
|
693
|
-
todo_ids: [resource.todo_ids[left], resource.todo_ids[right]],
|
|
694
|
-
resource_id: resource.resource_id,
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
conflicts.sort((left, right) => compareText(
|
|
700
|
-
`${left.todo_ids[0]}\0${left.todo_ids[1]}\0${left.resource_id}`,
|
|
701
|
-
`${right.todo_ids[0]}\0${right.todo_ids[1]}\0${right.resource_id}`,
|
|
702
|
-
));
|
|
703
|
-
unknowns.sort((left, right) => compareText(
|
|
704
|
-
`${left.todo_id}\0${left.kind}\0${left.reason}`,
|
|
705
|
-
`${right.todo_id}\0${right.kind}\0${right.reason}`,
|
|
706
|
-
));
|
|
707
|
-
|
|
708
|
-
return derivationResult({
|
|
709
|
-
outcome: unknowns.length > 0 ? 'unknown' : 'derived',
|
|
710
|
-
virtualWitness: virtualRequest.manual_witness,
|
|
711
|
-
hypotheses,
|
|
712
|
-
resources,
|
|
713
|
-
conflicts,
|
|
714
|
-
unknowns,
|
|
715
|
-
unresolvedWitnesses: unresolved,
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
export function createVirtualCompileReceipt(options = {}) {
|
|
720
|
-
const derivation = deriveVirtualBoundary(options);
|
|
721
|
-
return {
|
|
722
|
-
derivation,
|
|
723
|
-
verification: {
|
|
724
|
-
virtual_compile_input_digest: derivation.input_digest,
|
|
725
|
-
virtual_compile_result_digest: derivation.result_digest,
|
|
726
|
-
residual_conflicts: derivation.conflicts,
|
|
727
|
-
},
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/**
|
|
732
|
-
* Re-derive from source inputs. This distinguishes a caller-written SHA-shaped receipt from a
|
|
733
|
-
* reproducible virtual compile.
|
|
734
|
-
*/
|
|
735
|
-
export function verifyVirtualCompileReceipt({ verification, ...options } = {}) {
|
|
736
|
-
const expected = createVirtualCompileReceipt(options);
|
|
737
|
-
const mismatches = [];
|
|
738
|
-
if (verification?.virtual_compile_input_digest
|
|
739
|
-
!== expected.verification.virtual_compile_input_digest) {
|
|
740
|
-
mismatches.push('virtual_compile_input_digest');
|
|
741
|
-
}
|
|
742
|
-
if (verification?.virtual_compile_result_digest
|
|
743
|
-
!== expected.verification.virtual_compile_result_digest) {
|
|
744
|
-
mismatches.push('virtual_compile_result_digest');
|
|
745
|
-
}
|
|
746
|
-
if (digestArtifact(verification?.residual_conflicts)
|
|
747
|
-
!== digestArtifact(expected.verification.residual_conflicts)) {
|
|
748
|
-
mismatches.push('residual_conflicts');
|
|
749
|
-
}
|
|
750
|
-
return {
|
|
751
|
-
valid: mismatches.length === 0,
|
|
752
|
-
mismatches,
|
|
753
|
-
expected: expected.verification,
|
|
754
|
-
derivation: expected.derivation,
|
|
755
|
-
};
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/** 宣言されたconcern symbolを、`query` operationの解決receiptから探す。 */
|
|
759
|
-
function resolvedSymbolPath(queries, name) {
|
|
760
|
-
const receipt = queries.find((query) => (
|
|
761
|
-
query.operation === 'query'
|
|
762
|
-
&& query.target === name
|
|
763
|
-
&& query.outcome === 'resolved'
|
|
764
|
-
&& query.resolved_name === name
|
|
765
|
-
&& typeof query.resolved_path === 'string'
|
|
766
|
-
&& query.resolved_path.length > 0
|
|
767
|
-
));
|
|
768
|
-
return receipt === undefined ? null : receipt.resolved_path;
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
function pathContains(resourcePath, symbolPath) {
|
|
772
|
-
return resourcePath.endsWith('/')
|
|
773
|
-
? symbolPath.startsWith(resourcePath)
|
|
774
|
-
: symbolPath === resourcePath;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* 宣言symbolのpathを、宣言した資源の内側で解く(ADR 0134)。
|
|
779
|
-
*
|
|
780
|
-
* 一意に解決した名前はそのまま返す。同名が複数fileに居た場合だけ、receiptが持つ候補を
|
|
781
|
-
* 資源で絞る——絞って1つに決まる時だけ束縛根拠になる。0個なら宣言した資源の中には無く、
|
|
782
|
-
* 2つ以上なら資源の中でも決まらない。どちらも「解けなかった」であって、片方を勝たせない。
|
|
783
|
-
*
|
|
784
|
-
* 絞るのは宣言された資源であって、宣言そのものではない。sensorが返した候補集合の外から
|
|
785
|
-
* pathを持ち込むことはないので、宣言が誤っていても存在しないsymbolへは束縛されない。
|
|
786
|
-
*/
|
|
787
|
-
function resolveConcernSymbolPath(queries, name, resourcePath) {
|
|
788
|
-
const receipt = queries.find((query) => (
|
|
789
|
-
query.operation === 'query' && query.target === name && query.resolved_name === name
|
|
790
|
-
&& (query.outcome === 'resolved' || query.outcome === 'ambiguous')
|
|
791
|
-
));
|
|
792
|
-
if (receipt === undefined) return null;
|
|
793
|
-
if (receipt.outcome === 'resolved') {
|
|
794
|
-
return typeof receipt.resolved_path === 'string' && receipt.resolved_path.length > 0
|
|
795
|
-
? receipt.resolved_path : null;
|
|
796
|
-
}
|
|
797
|
-
const inside = (receipt.candidate_paths ?? [])
|
|
798
|
-
.filter((candidate) => pathContains(resourcePath, candidate));
|
|
799
|
-
return inside.length === 1 ? inside[0] : null;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/**
|
|
803
|
-
* Resolve declared concern anchors against fresh sensor evidence.
|
|
804
|
-
*
|
|
805
|
-
* A declaration only becomes a binding anchor when the sensor resolves the exact name inside the
|
|
806
|
-
* declared resource to exactly one path. Fuzzy resolution to a neighbouring symbol, an absent
|
|
807
|
-
* name, or a symbol living outside the contested resource yields a typed unknown instead — a
|
|
808
|
-
* wrong declaration must never widen what the binder believes it knows.
|
|
809
|
-
*
|
|
810
|
-
* A name living in several files is resolved against the declared resource rather than being
|
|
811
|
-
* dropped (ADR 0134). The resource is one the ToDo already claims under `owns`, so this narrows
|
|
812
|
-
* the sensor's own candidate set; it never introduces a path the sensor did not report.
|
|
813
|
-
*
|
|
814
|
-
* Two ToDos claiming the same symbol is a contradiction in the declarations themselves, not a cut
|
|
815
|
-
* to be discovered: the anchor is dropped from both and reported, so neither side can be bound by
|
|
816
|
-
* a claim the other also makes.
|
|
817
|
-
*/
|
|
818
|
-
export function resolveConcernAnchors({ manualWitness, taskIds, evidence } = {}) {
|
|
819
|
-
if (!plainRecord(manualWitness) || !Array.isArray(taskIds) || !plainRecord(evidence)) {
|
|
820
|
-
fail('concern anchor resolution input shapeが不正');
|
|
821
|
-
}
|
|
822
|
-
// receiptが1件も無い証拠は「解決しなかった」であって、宣言を素通しさせる理由にはしない。
|
|
823
|
-
const queries = Array.isArray(evidence.queries) ? evidence.queries : [];
|
|
824
|
-
const anchorsByTask = new Map();
|
|
825
|
-
const unknowns = [];
|
|
826
|
-
for (const taskId of taskIds) {
|
|
827
|
-
const anchors = [];
|
|
828
|
-
for (const entry of manualWitness[taskId]?.concern_anchors ?? []) {
|
|
829
|
-
const resourcePath = entry.within.kind === 'path'
|
|
830
|
-
? entry.within.target
|
|
831
|
-
: resolvedSymbolPath(queries, entry.within.target);
|
|
832
|
-
if (resourcePath === null) {
|
|
833
|
-
unknowns.push({
|
|
834
|
-
kind: 'concern_anchor_resource_unresolved',
|
|
835
|
-
ref: `${taskId}:${entry.within.kind}:${entry.within.target}`,
|
|
836
|
-
});
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
for (const symbol of entry.symbols) {
|
|
840
|
-
const symbolPath = resolveConcernSymbolPath(queries, symbol, resourcePath);
|
|
841
|
-
if (symbolPath === null) {
|
|
842
|
-
unknowns.push({ kind: 'concern_anchor_unresolved', ref: `${taskId}:${symbol}` });
|
|
843
|
-
continue;
|
|
844
|
-
}
|
|
845
|
-
if (!pathContains(resourcePath, symbolPath)) {
|
|
846
|
-
unknowns.push({
|
|
847
|
-
kind: 'concern_anchor_outside_resource',
|
|
848
|
-
ref: `${taskId}:${symbol}:${symbolPath}`,
|
|
849
|
-
});
|
|
850
|
-
continue;
|
|
851
|
-
}
|
|
852
|
-
anchors.push(`concern:${symbolPath}\0${symbol}`);
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
anchorsByTask.set(taskId, sortedUnique(anchors));
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// 同じsymbolを2 task以上が担当と主張したら、どちらの束縛根拠にもしない。
|
|
859
|
-
const claimantsByAnchor = new Map();
|
|
860
|
-
for (const [taskId, anchors] of anchorsByTask) {
|
|
861
|
-
for (const anchor of anchors) {
|
|
862
|
-
if (!claimantsByAnchor.has(anchor)) claimantsByAnchor.set(anchor, []);
|
|
863
|
-
claimantsByAnchor.get(anchor).push(taskId);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
const overlapping = new Set();
|
|
867
|
-
for (const [anchor, claimants] of claimantsByAnchor) {
|
|
868
|
-
if (claimants.length < 2) continue;
|
|
869
|
-
overlapping.add(anchor);
|
|
870
|
-
const [path, symbol] = anchor.slice('concern:'.length).split('\0');
|
|
871
|
-
unknowns.push({
|
|
872
|
-
kind: 'concern_anchor_overlap',
|
|
873
|
-
ref: `${[...claimants].sort(compareText).join(',')}:${path}:${symbol}`,
|
|
874
|
-
});
|
|
875
|
-
}
|
|
876
|
-
if (overlapping.size > 0) {
|
|
877
|
-
for (const [taskId, anchors] of anchorsByTask) {
|
|
878
|
-
anchorsByTask.set(taskId, anchors.filter((anchor) => !overlapping.has(anchor)));
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
return {
|
|
883
|
-
anchorsByTask,
|
|
884
|
-
unknowns: unknowns.sort((left, right) => compareText(
|
|
885
|
-
`${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
|
|
886
|
-
)),
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/** witness set全体から、sensorへ問い合わせるconcern symbol名を集める。 */
|
|
891
|
-
export function declaredConcernSymbols(manualWitness) {
|
|
892
|
-
if (!plainRecord(manualWitness)) fail('manual witness shapeが不正');
|
|
893
|
-
const names = [];
|
|
894
|
-
for (const witness of Object.values(manualWitness)) {
|
|
895
|
-
for (const entry of witness?.concern_anchors ?? []) {
|
|
896
|
-
names.push(...entry.symbols);
|
|
897
|
-
if (entry.within.kind === 'symbol') names.push(entry.within.target);
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
return sortedUnique(names);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
function uniqueIntentAnchors(manualWitness, taskIds) {
|
|
904
|
-
const anchorsByTask = new Map();
|
|
905
|
-
const counts = new Map();
|
|
906
|
-
for (const taskId of taskIds) {
|
|
907
|
-
const witness = manualWitness[taskId];
|
|
908
|
-
const anchors = [
|
|
909
|
-
...witness.owns.map((own) => `owns:${resourceKey(own)}`),
|
|
910
|
-
...witness.writes.map((path) => `writes:${path}`),
|
|
911
|
-
...witness.affected_tests.map((path) => `affected_tests:${path}`),
|
|
912
|
-
];
|
|
913
|
-
anchorsByTask.set(taskId, sortedUnique(anchors));
|
|
914
|
-
for (const anchor of new Set(anchors)) counts.set(anchor, (counts.get(anchor) ?? 0) + 1);
|
|
915
|
-
}
|
|
916
|
-
return new Map([...anchorsByTask].map(([taskId, anchors]) => [
|
|
917
|
-
taskId,
|
|
918
|
-
anchors.filter((anchor) => counts.get(anchor) === 1),
|
|
919
|
-
]));
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
function graphNode(entry) {
|
|
923
|
-
const node = plainRecord(entry?.node) ? entry.node : entry;
|
|
924
|
-
if (!plainRecord(node)
|
|
925
|
-
|| typeof node.name !== 'string'
|
|
926
|
-
|| node.name.length === 0
|
|
927
|
-
|| typeof node.filePath !== 'string'
|
|
928
|
-
|| node.filePath.length === 0
|
|
929
|
-
|| node.filePath.startsWith('/')) return null;
|
|
930
|
-
if (node.kind === 'file') {
|
|
931
|
-
return { kind: 'path', target: node.filePath, path: node.filePath };
|
|
932
|
-
}
|
|
933
|
-
return { kind: 'symbol', target: node.name, path: node.filePath };
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
function graphNodeKey(node) {
|
|
937
|
-
return `${node.kind}\0${node.target}\0${node.path}`;
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
function graphEdgeKey(edge) {
|
|
941
|
-
return `${edge.from}\0${edge.to}\0${edge.kind}`;
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
function graphPayload(outcome, operation) {
|
|
945
|
-
if (!plainRecord(outcome) || outcome.outcome !== 'ready') return null;
|
|
946
|
-
if (operation === 'query') return outcome.data;
|
|
947
|
-
if (!plainRecord(outcome.data)) return null;
|
|
948
|
-
if (operation === 'callers') return outcome.data.callers;
|
|
949
|
-
if (operation === 'callees') return outcome.data.callees;
|
|
950
|
-
if (operation === 'impact') return outcome.data.affected;
|
|
951
|
-
return null;
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
function normalizeRawGraph({ conflict, evidence, rawCollected }) {
|
|
955
|
-
if (!plainRecord(rawCollected) || !Array.isArray(rawCollected.outcomes)) {
|
|
956
|
-
return { graph: null, unknown: 'raw_graph_unavailable' };
|
|
957
|
-
}
|
|
958
|
-
const receipts = evidence.queries.filter((query) => (
|
|
959
|
-
query.target === conflict.target && STRUCTURE_OPERATIONS.has(query.operation)
|
|
960
|
-
));
|
|
961
|
-
if (receipts.length !== STRUCTURE_OPERATIONS.size
|
|
962
|
-
|| receipts.some((query) => query.outcome !== 'resolved'
|
|
963
|
-
|| query.resolved_name !== conflict.target
|
|
964
|
-
|| query.resolved_path === null)) {
|
|
965
|
-
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
966
|
-
}
|
|
967
|
-
const outcomeById = new Map(rawCollected.outcomes.map((outcome) => [outcome?.id, outcome]));
|
|
968
|
-
const receiptByOperation = new Map(receipts.map((receipt) => [receipt.operation, receipt]));
|
|
969
|
-
const payloadByOperation = new Map();
|
|
970
|
-
for (const operation of STRUCTURE_OPERATIONS) {
|
|
971
|
-
const receipt = receiptByOperation.get(operation);
|
|
972
|
-
const payload = graphPayload(outcomeById.get(receipt.query_id), operation);
|
|
973
|
-
if (!Array.isArray(payload)) {
|
|
974
|
-
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
975
|
-
}
|
|
976
|
-
payloadByOperation.set(operation, payload);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const queryReceipt = receiptByOperation.get('query');
|
|
980
|
-
const rootMatches = payloadByOperation.get('query')
|
|
981
|
-
.map(graphNode)
|
|
982
|
-
.filter((node) => node !== null
|
|
983
|
-
&& node.kind === 'symbol'
|
|
984
|
-
&& node.target === conflict.target
|
|
985
|
-
&& node.path === queryReceipt.resolved_path);
|
|
986
|
-
if (rootMatches.length !== 1) {
|
|
987
|
-
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
988
|
-
}
|
|
989
|
-
const root = rootMatches[0];
|
|
990
|
-
const nodes = new Map([[graphNodeKey(root), root]]);
|
|
991
|
-
const edges = new Map();
|
|
992
|
-
let invalidObservedNode = false;
|
|
993
|
-
const addObserved = (entry) => {
|
|
994
|
-
const node = graphNode(entry);
|
|
995
|
-
if (node === null) invalidObservedNode = true;
|
|
996
|
-
if (node !== null) nodes.set(graphNodeKey(node), node);
|
|
997
|
-
return node;
|
|
998
|
-
};
|
|
999
|
-
for (const entry of payloadByOperation.get('callers')) {
|
|
1000
|
-
const node = addObserved(entry);
|
|
1001
|
-
if (node === null) continue;
|
|
1002
|
-
const edge = { from: graphNodeKey(node), to: graphNodeKey(root), kind: 'caller' };
|
|
1003
|
-
edges.set(graphEdgeKey(edge), edge);
|
|
1004
|
-
}
|
|
1005
|
-
for (const entry of payloadByOperation.get('callees')) {
|
|
1006
|
-
const node = addObserved(entry);
|
|
1007
|
-
if (node === null) continue;
|
|
1008
|
-
const edge = { from: graphNodeKey(root), to: graphNodeKey(node), kind: 'callee' };
|
|
1009
|
-
edges.set(graphEdgeKey(edge), edge);
|
|
1010
|
-
}
|
|
1011
|
-
for (const entry of payloadByOperation.get('impact')) addObserved(entry);
|
|
1012
|
-
let closureComplete = plainRecord(rawCollected.graph_closure)
|
|
1013
|
-
&& rawCollected.graph_closure.complete === true
|
|
1014
|
-
&& Array.isArray(rawCollected.graph_closure.expansions);
|
|
1015
|
-
if (plainRecord(rawCollected.graph_closure)
|
|
1016
|
-
&& Array.isArray(rawCollected.graph_closure.expansions)) {
|
|
1017
|
-
for (const expansion of rawCollected.graph_closure.expansions) {
|
|
1018
|
-
const parent = graphNode(expansion?.parent);
|
|
1019
|
-
const rawCallees = expansion?.callees_outcome;
|
|
1020
|
-
if (expansion?.exact !== true
|
|
1021
|
-
|| parent === null
|
|
1022
|
-
|| !plainRecord(rawCallees)
|
|
1023
|
-
|| rawCallees.outcome !== 'ready'
|
|
1024
|
-
|| !plainRecord(rawCallees.data)
|
|
1025
|
-
|| !Array.isArray(rawCallees.data.callees)) {
|
|
1026
|
-
closureComplete = false;
|
|
1027
|
-
continue;
|
|
1028
|
-
}
|
|
1029
|
-
nodes.set(graphNodeKey(parent), parent);
|
|
1030
|
-
for (const entry of rawCallees.data.callees) {
|
|
1031
|
-
const child = addObserved(entry);
|
|
1032
|
-
if (child === null) {
|
|
1033
|
-
closureComplete = false;
|
|
1034
|
-
continue;
|
|
1035
|
-
}
|
|
1036
|
-
const edge = {
|
|
1037
|
-
from: graphNodeKey(parent),
|
|
1038
|
-
to: graphNodeKey(child),
|
|
1039
|
-
kind: 'callee',
|
|
1040
|
-
};
|
|
1041
|
-
edges.set(graphEdgeKey(edge), edge);
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
if (invalidObservedNode) {
|
|
1046
|
-
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
1047
|
-
}
|
|
1048
|
-
return {
|
|
1049
|
-
graph: {
|
|
1050
|
-
root,
|
|
1051
|
-
closure_complete: closureComplete,
|
|
1052
|
-
nodes: [...nodes.values()].sort((left, right) => compareText(
|
|
1053
|
-
graphNodeKey(left), graphNodeKey(right),
|
|
1054
|
-
)),
|
|
1055
|
-
edges: [...edges.values()].sort((left, right) => compareText(
|
|
1056
|
-
graphEdgeKey(left), graphEdgeKey(right),
|
|
1057
|
-
)),
|
|
1058
|
-
},
|
|
1059
|
-
unknown: null,
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
function stronglyConnectedPartitions(graph) {
|
|
1064
|
-
const symbolKeys = new Set(graph.nodes
|
|
1065
|
-
.filter(({ kind }) => kind === 'symbol').map(graphNodeKey));
|
|
1066
|
-
const adjacency = new Map([...symbolKeys].map((key) => [key, []]));
|
|
1067
|
-
for (const edge of graph.edges) {
|
|
1068
|
-
if (symbolKeys.has(edge.from) && symbolKeys.has(edge.to)) {
|
|
1069
|
-
adjacency.get(edge.from).push(edge.to);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
for (const targets of adjacency.values()) targets.sort(compareText);
|
|
1073
|
-
let nextIndex = 0;
|
|
1074
|
-
const indexes = new Map();
|
|
1075
|
-
const lowLinks = new Map();
|
|
1076
|
-
const stack = [];
|
|
1077
|
-
const onStack = new Set();
|
|
1078
|
-
const components = [];
|
|
1079
|
-
const visit = (key) => {
|
|
1080
|
-
indexes.set(key, nextIndex);
|
|
1081
|
-
lowLinks.set(key, nextIndex);
|
|
1082
|
-
nextIndex += 1;
|
|
1083
|
-
stack.push(key);
|
|
1084
|
-
onStack.add(key);
|
|
1085
|
-
for (const target of adjacency.get(key)) {
|
|
1086
|
-
if (!indexes.has(target)) {
|
|
1087
|
-
visit(target);
|
|
1088
|
-
lowLinks.set(key, Math.min(lowLinks.get(key), lowLinks.get(target)));
|
|
1089
|
-
} else if (onStack.has(target)) {
|
|
1090
|
-
lowLinks.set(key, Math.min(lowLinks.get(key), indexes.get(target)));
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
if (lowLinks.get(key) !== indexes.get(key)) return;
|
|
1094
|
-
const component = [];
|
|
1095
|
-
while (stack.length > 0) {
|
|
1096
|
-
const member = stack.pop();
|
|
1097
|
-
onStack.delete(member);
|
|
1098
|
-
component.push(member);
|
|
1099
|
-
if (member === key) break;
|
|
1100
|
-
}
|
|
1101
|
-
components.push(component.sort(compareText));
|
|
1102
|
-
};
|
|
1103
|
-
for (const key of [...symbolKeys].sort(compareText)) {
|
|
1104
|
-
if (!indexes.has(key)) visit(key);
|
|
1105
|
-
}
|
|
1106
|
-
return components.sort((left, right) => compareText(left.join('\0'), right.join('\0')));
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
function calleeClosurePartitions(graph) {
|
|
1110
|
-
const adjacency = new Map(graph.nodes.map((node) => [graphNodeKey(node), []]));
|
|
1111
|
-
for (const edge of graph.edges.filter(({ kind }) => kind === 'callee')) {
|
|
1112
|
-
adjacency.get(edge.from)?.push(edge.to);
|
|
1113
|
-
}
|
|
1114
|
-
for (const targets of adjacency.values()) targets.sort(compareText);
|
|
1115
|
-
const direct = adjacency.get(graphNodeKey(graph.root)) ?? [];
|
|
1116
|
-
return direct.map((start) => {
|
|
1117
|
-
const seen = new Set();
|
|
1118
|
-
const queue = [start];
|
|
1119
|
-
while (queue.length > 0) {
|
|
1120
|
-
const key = queue.shift();
|
|
1121
|
-
if (seen.has(key)) continue;
|
|
1122
|
-
seen.add(key);
|
|
1123
|
-
queue.push(...(adjacency.get(key) ?? []));
|
|
1124
|
-
}
|
|
1125
|
-
return [...seen].sort(compareText);
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
function moduleFrontierPartitions(graph) {
|
|
1130
|
-
const byPath = new Map();
|
|
1131
|
-
for (const node of graph.nodes) {
|
|
1132
|
-
if (!byPath.has(node.path)) byPath.set(node.path, []);
|
|
1133
|
-
byPath.get(node.path).push(graphNodeKey(node));
|
|
1134
|
-
}
|
|
1135
|
-
return [...byPath.entries()].sort((left, right) => compareText(left[0], right[0]))
|
|
1136
|
-
.map(([, keys]) => keys.sort(compareText));
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
function testFrontierPartitions(graph) {
|
|
1140
|
-
return graph.nodes
|
|
1141
|
-
.filter(({ kind, path }) => kind === 'path'
|
|
1142
|
-
&& (path.startsWith('test/') || /\.test\.[cm]?[jt]sx?$/u.test(path)))
|
|
1143
|
-
.map((node) => [graphNodeKey(node)]);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
function canonicalPartitions(partitions) {
|
|
1147
|
-
const unique = new Set();
|
|
1148
|
-
for (const partition of partitions) {
|
|
1149
|
-
const key = sortedUnique(partition).join('\u0001');
|
|
1150
|
-
if (key.length > 0) unique.add(key);
|
|
1151
|
-
}
|
|
1152
|
-
return [...unique].sort(compareText).map((key) => key.split('\u0001'));
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
function anchorMatchesNode(anchor, node) {
|
|
1156
|
-
if (anchor.startsWith('concern:')) {
|
|
1157
|
-
const separator = anchor.indexOf('\0');
|
|
1158
|
-
return node.kind === 'symbol'
|
|
1159
|
-
&& node.path === anchor.slice('concern:'.length, separator)
|
|
1160
|
-
&& node.target === anchor.slice(separator + 1);
|
|
1161
|
-
}
|
|
1162
|
-
if (anchor.startsWith('owns:symbol\0')) {
|
|
1163
|
-
return node.kind === 'symbol' && node.target === anchor.slice('owns:symbol\0'.length);
|
|
1164
|
-
}
|
|
1165
|
-
if (anchor.startsWith('owns:path\0')) {
|
|
1166
|
-
return node.path === anchor.slice('owns:path\0'.length);
|
|
1167
|
-
}
|
|
1168
|
-
if (anchor.startsWith('writes:')) return node.path === anchor.slice('writes:'.length);
|
|
1169
|
-
if (anchor.startsWith('affected_tests:')) {
|
|
1170
|
-
return node.path === anchor.slice('affected_tests:'.length);
|
|
1171
|
-
}
|
|
1172
|
-
return false;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* Bind tasks to partitions.
|
|
1177
|
-
*
|
|
1178
|
-
* Declared concern anchors take precedence over the coarse owns/writes/test anchors within one
|
|
1179
|
-
* skeleton: a ToDo that named the symbols it touches inside the contested resource has given
|
|
1180
|
-
* strictly more specific evidence than "it owns some file". The coarse anchors stay as the
|
|
1181
|
-
* fallback for skeletons the declaration says nothing about, so declaring a concern for one
|
|
1182
|
-
* conflict never blinds the binder to another.
|
|
1183
|
-
*/
|
|
1184
|
-
function bindSkeleton({ skeleton, graph, intentAnchors, concernAnchors, taskIds }) {
|
|
1185
|
-
const nodeByKey = new Map(graph.nodes.map((node) => [graphNodeKey(node), node]));
|
|
1186
|
-
const taskBindings = [];
|
|
1187
|
-
const unknowns = [];
|
|
1188
|
-
const matchesFor = (anchors) => {
|
|
1189
|
-
const matched = [];
|
|
1190
|
-
skeleton.partitions.forEach((partition, index) => {
|
|
1191
|
-
const hits = anchors.filter((anchor) => (
|
|
1192
|
-
partition.some((key) => anchorMatchesNode(anchor, nodeByKey.get(key)))
|
|
1193
|
-
));
|
|
1194
|
-
if (hits.length > 0) matched.push({ index, anchors: sortedUnique(hits) });
|
|
1195
|
-
});
|
|
1196
|
-
return matched;
|
|
1197
|
-
};
|
|
1198
|
-
for (const taskId of taskIds) {
|
|
1199
|
-
const declared = matchesFor(concernAnchors.get(taskId) ?? []);
|
|
1200
|
-
const matches = declared.length > 0 ? declared : matchesFor(intentAnchors.get(taskId));
|
|
1201
|
-
if (matches.length === 0) {
|
|
1202
|
-
unknowns.push({
|
|
1203
|
-
kind: 'semantic_owner_binding_missing',
|
|
1204
|
-
ref: `${skeleton.skeleton_id}:${taskId}`,
|
|
1205
|
-
});
|
|
1206
|
-
continue;
|
|
1207
|
-
}
|
|
1208
|
-
if (matches.length > 1) {
|
|
1209
|
-
unknowns.push({
|
|
1210
|
-
kind: 'semantic_owner_binding_ambiguous',
|
|
1211
|
-
ref: `${skeleton.skeleton_id}:${taskId}`,
|
|
1212
|
-
});
|
|
1213
|
-
continue;
|
|
1214
|
-
}
|
|
1215
|
-
taskBindings.push({
|
|
1216
|
-
task_id: taskId,
|
|
1217
|
-
partition_index: matches[0].index,
|
|
1218
|
-
anchors: matches[0].anchors,
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
if (taskBindings.length === taskIds.length
|
|
1222
|
-
&& new Set(taskBindings.map(({ partition_index: index }) => index)).size
|
|
1223
|
-
!== taskBindings.length) {
|
|
1224
|
-
unknowns.push({
|
|
1225
|
-
kind: 'semantic_owner_binding_ambiguous',
|
|
1226
|
-
ref: `${skeleton.skeleton_id}:shared_partition`,
|
|
1227
|
-
});
|
|
1228
|
-
}
|
|
1229
|
-
return {
|
|
1230
|
-
...skeleton,
|
|
1231
|
-
task_bindings: taskBindings,
|
|
1232
|
-
binding_unknowns: unknowns,
|
|
1233
|
-
};
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
/**
|
|
1237
|
-
* Build a cut skeleton for a contested repo path out of the declared concern anchors alone.
|
|
1238
|
-
*
|
|
1239
|
-
* A path conflict has no call graph to partition — the sensor was only asked which tests the file
|
|
1240
|
-
* affects. What the declarations do give is a partition of the file's symbols by owner, which is
|
|
1241
|
-
* exactly the shape a cut needs. Every task in the component must have named at least one symbol
|
|
1242
|
-
* inside the path; otherwise there is nothing to say about who owns which half and the caller
|
|
1243
|
-
* keeps reporting the resource as unavailable rather than guessing.
|
|
1244
|
-
*/
|
|
1245
|
-
function declaredPartitionSkeleton({ conflict, concernAnchors, taskIds }) {
|
|
1246
|
-
const rootPath = conflict.target;
|
|
1247
|
-
const root = { kind: 'path', target: rootPath, path: rootPath };
|
|
1248
|
-
const nodes = [root];
|
|
1249
|
-
const partitions = [];
|
|
1250
|
-
for (const taskId of taskIds) {
|
|
1251
|
-
const owned = (concernAnchors.get(taskId) ?? [])
|
|
1252
|
-
.map((anchor) => {
|
|
1253
|
-
const separator = anchor.indexOf('\0');
|
|
1254
|
-
return {
|
|
1255
|
-
path: anchor.slice('concern:'.length, separator),
|
|
1256
|
-
name: anchor.slice(separator + 1),
|
|
1257
|
-
};
|
|
1258
|
-
})
|
|
1259
|
-
.filter(({ path }) => pathContains(rootPath, path));
|
|
1260
|
-
if (owned.length === 0) return null;
|
|
1261
|
-
for (const { path, name } of owned) nodes.push({ kind: 'symbol', target: name, path });
|
|
1262
|
-
partitions.push(owned.map(({ path, name }) => graphNodeKey({
|
|
1263
|
-
kind: 'symbol', target: name, path,
|
|
1264
|
-
})));
|
|
1265
|
-
}
|
|
1266
|
-
return {
|
|
1267
|
-
root,
|
|
1268
|
-
nodes,
|
|
1269
|
-
partitions: canonicalPartitions(partitions),
|
|
1270
|
-
};
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
/**
|
|
1274
|
-
* Enumerate structural cut skeletons from the in-memory sensor outcomes. Graph edges only shape
|
|
1275
|
-
* SCC/closure/frontier partitions; task ownership is bound exclusively by unique witness anchors
|
|
1276
|
-
* and by declared concern anchors, never by the edges themselves.
|
|
1277
|
-
*/
|
|
1278
|
-
export function enumerateCutSkeletons({
|
|
1279
|
-
component,
|
|
1280
|
-
request,
|
|
1281
|
-
evidence,
|
|
1282
|
-
rawCollected,
|
|
1283
|
-
concernAnchors = new Map(),
|
|
1284
|
-
} = {}) {
|
|
1285
|
-
if (!plainRecord(component)
|
|
1286
|
-
|| !Array.isArray(component.task_ids)
|
|
1287
|
-
|| !Array.isArray(component.conflicts)
|
|
1288
|
-
|| !plainRecord(request)
|
|
1289
|
-
|| !plainRecord(evidence)) {
|
|
1290
|
-
fail('cut skeleton enumeration input shapeが不正');
|
|
1291
|
-
}
|
|
1292
|
-
const taskIds = [...component.task_ids].sort(compareText);
|
|
1293
|
-
const intentAnchors = uniqueIntentAnchors(request.manual_witness, taskIds);
|
|
1294
|
-
const missingIntent = taskIds.filter((taskId) => (
|
|
1295
|
-
intentAnchors.get(taskId).length === 0 && (concernAnchors.get(taskId) ?? []).length === 0
|
|
1296
|
-
));
|
|
1297
|
-
if (missingIntent.length > 0) {
|
|
1298
|
-
return {
|
|
1299
|
-
skeletons: [],
|
|
1300
|
-
unknowns: missingIntent.map((taskId) => ({
|
|
1301
|
-
kind: 'semantic_owner_binding_missing',
|
|
1302
|
-
ref: taskId,
|
|
1303
|
-
})),
|
|
1304
|
-
exploration_complete: false,
|
|
1305
|
-
};
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
const skeletonByLayout = new Map();
|
|
1309
|
-
const unknowns = [];
|
|
1310
|
-
for (const conflict of component.conflicts) {
|
|
1311
|
-
if (conflict.kind === 'path') {
|
|
1312
|
-
const declared = declaredPartitionSkeleton({ conflict, concernAnchors, taskIds });
|
|
1313
|
-
if (declared === null || declared.partitions.length < 2) {
|
|
1314
|
-
unknowns.push({ kind: 'raw_graph_unavailable', ref: conflict.resource_id });
|
|
1315
|
-
continue;
|
|
1316
|
-
}
|
|
1317
|
-
const layoutKey = digestArtifact({
|
|
1318
|
-
conflict_resource_id: conflict.resource_id,
|
|
1319
|
-
partitions: declared.partitions,
|
|
1320
|
-
});
|
|
1321
|
-
skeletonByLayout.set(layoutKey, {
|
|
1322
|
-
skeleton_id: `cut-${sha16(layoutKey)}`,
|
|
1323
|
-
conflict_resource_id: conflict.resource_id,
|
|
1324
|
-
cut_kinds: ['declared_partition'],
|
|
1325
|
-
root_surface: structuredClone(declared.root),
|
|
1326
|
-
partitions: declared.partitions,
|
|
1327
|
-
raw_graph: { nodes: structuredClone(declared.nodes), edges: [] },
|
|
1328
|
-
});
|
|
1329
|
-
continue;
|
|
1330
|
-
}
|
|
1331
|
-
if (conflict.kind !== 'symbol') {
|
|
1332
|
-
unknowns.push({ kind: 'raw_graph_unavailable', ref: conflict.resource_id });
|
|
1333
|
-
continue;
|
|
1334
|
-
}
|
|
1335
|
-
const normalized = normalizeRawGraph({ conflict, evidence, rawCollected });
|
|
1336
|
-
if (normalized.graph === null) {
|
|
1337
|
-
unknowns.push({ kind: normalized.unknown, ref: conflict.resource_id });
|
|
1338
|
-
continue;
|
|
1339
|
-
}
|
|
1340
|
-
const graph = normalized.graph;
|
|
1341
|
-
if (!graph.closure_complete) {
|
|
1342
|
-
unknowns.push({ kind: 'raw_graph_incomplete', ref: conflict.resource_id });
|
|
1343
|
-
}
|
|
1344
|
-
const variants = [
|
|
1345
|
-
...(graph.closure_complete ? [
|
|
1346
|
-
['scc', stronglyConnectedPartitions(graph)],
|
|
1347
|
-
['callee_closure', calleeClosurePartitions(graph)],
|
|
1348
|
-
] : []),
|
|
1349
|
-
['module_frontier', moduleFrontierPartitions(graph)],
|
|
1350
|
-
['task_test_frontier', testFrontierPartitions(graph)],
|
|
1351
|
-
];
|
|
1352
|
-
for (const [cutKind, rawPartitions] of variants) {
|
|
1353
|
-
const partitions = canonicalPartitions(rawPartitions);
|
|
1354
|
-
if (partitions.length < 2) continue;
|
|
1355
|
-
const layoutKey = digestArtifact({
|
|
1356
|
-
conflict_resource_id: conflict.resource_id,
|
|
1357
|
-
partitions,
|
|
1358
|
-
});
|
|
1359
|
-
const existing = skeletonByLayout.get(layoutKey);
|
|
1360
|
-
if (existing !== undefined) {
|
|
1361
|
-
existing.cut_kinds = sortedUnique([...existing.cut_kinds, cutKind]);
|
|
1362
|
-
continue;
|
|
1363
|
-
}
|
|
1364
|
-
skeletonByLayout.set(layoutKey, {
|
|
1365
|
-
skeleton_id: `cut-${sha16(layoutKey)}`,
|
|
1366
|
-
conflict_resource_id: conflict.resource_id,
|
|
1367
|
-
cut_kinds: [cutKind],
|
|
1368
|
-
root_surface: structuredClone(graph.root),
|
|
1369
|
-
partitions,
|
|
1370
|
-
raw_graph: {
|
|
1371
|
-
nodes: structuredClone(graph.nodes),
|
|
1372
|
-
edges: structuredClone(graph.edges),
|
|
1373
|
-
},
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
const skeletons = [...skeletonByLayout.values()]
|
|
1378
|
-
.sort((left, right) => compareText(left.skeleton_id, right.skeleton_id))
|
|
1379
|
-
.map((skeleton) => bindSkeleton({
|
|
1380
|
-
skeleton,
|
|
1381
|
-
graph: skeleton.raw_graph,
|
|
1382
|
-
intentAnchors,
|
|
1383
|
-
concernAnchors,
|
|
1384
|
-
taskIds,
|
|
1385
|
-
}));
|
|
1386
|
-
for (const skeleton of skeletons) unknowns.push(...skeleton.binding_unknowns);
|
|
1387
|
-
if (skeletons.length === 0 && unknowns.length === 0) {
|
|
1388
|
-
unknowns.push({ kind: 'raw_graph_incomplete', ref: component.component_id });
|
|
1389
|
-
}
|
|
1390
|
-
return {
|
|
1391
|
-
skeletons,
|
|
1392
|
-
unknowns: unknowns.sort((left, right) => compareText(
|
|
1393
|
-
`${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
|
|
1394
|
-
)),
|
|
1395
|
-
exploration_complete: unknowns.length === 0,
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
function rawGraphSurfaceSet(rawGraph) {
|
|
1400
|
-
if (!exactRecord(rawGraph, ['nodes', 'edges'])
|
|
1401
|
-
|| !Array.isArray(rawGraph.nodes)
|
|
1402
|
-
|| !Array.isArray(rawGraph.edges)) return null;
|
|
1403
|
-
const surfaces = new Set();
|
|
1404
|
-
for (const node of rawGraph.nodes) {
|
|
1405
|
-
if (!exactRecord(node, ['kind', 'target', 'path'])
|
|
1406
|
-
|| !['symbol', 'path'].includes(node.kind)
|
|
1407
|
-
|| typeof node.target !== 'string'
|
|
1408
|
-
|| typeof node.path !== 'string') return null;
|
|
1409
|
-
surfaces.add(`${node.kind}\0${node.target}\0${node.path}`);
|
|
1410
|
-
}
|
|
1411
|
-
return surfaces;
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
function currentSurfaces(component, evidence) {
|
|
1415
|
-
const ownerByConflict = new Map(component.conflicts.map((conflict) => [
|
|
1416
|
-
conflict.resource_id,
|
|
1417
|
-
sortedUnique(conflict.task_pairs.flat()),
|
|
1418
|
-
]));
|
|
1419
|
-
const surfaces = [];
|
|
1420
|
-
for (const conflict of component.conflicts) {
|
|
1421
|
-
if (!['symbol', 'path'].includes(conflict.kind)) continue;
|
|
1422
|
-
let path = conflict.target;
|
|
1423
|
-
if (conflict.kind === 'symbol') {
|
|
1424
|
-
const receipt = evidence.queries.find((query) => (
|
|
1425
|
-
query.target === conflict.target
|
|
1426
|
-
&& query.outcome === 'resolved'
|
|
1427
|
-
&& query.resolved_name === conflict.target
|
|
1428
|
-
&& query.resolved_path !== null
|
|
1429
|
-
));
|
|
1430
|
-
if (receipt === undefined) return null;
|
|
1431
|
-
path = receipt.resolved_path;
|
|
1432
|
-
}
|
|
1433
|
-
surfaces.push({
|
|
1434
|
-
kind: conflict.kind,
|
|
1435
|
-
target: conflict.target,
|
|
1436
|
-
path,
|
|
1437
|
-
role: conflict.kind === 'symbol' ? 'shared_symbol' : 'shared_path',
|
|
1438
|
-
owner_task_ids: ownerByConflict.get(conflict.resource_id),
|
|
1439
|
-
});
|
|
1440
|
-
}
|
|
1441
|
-
return surfaces.sort((left, right) => compareText(surfaceKey(left), surfaceKey(right)));
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
function candidateDominates(left, right) {
|
|
1445
|
-
const noWorse = left.minimumWaves <= right.minimumWaves
|
|
1446
|
-
&& isSubset(left.changedSurfaces, right.changedSurfaces)
|
|
1447
|
-
&& isSubset(left.blastRadius, right.blastRadius);
|
|
1448
|
-
const strict = left.minimumWaves < right.minimumWaves
|
|
1449
|
-
|| left.changedSurfaces.size < right.changedSurfaces.size
|
|
1450
|
-
|| left.blastRadius.size < right.blastRadius.size;
|
|
1451
|
-
return noWorse && strict;
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
function decisionUnknown(component, unknowns, reasons = []) {
|
|
1455
|
-
return {
|
|
1456
|
-
component_id: component.component_id,
|
|
1457
|
-
task_ids: [...component.task_ids],
|
|
1458
|
-
conflicts: structuredClone(component.conflicts),
|
|
1459
|
-
verdict: 'unknown_requires_evidence',
|
|
1460
|
-
seam_candidate: null,
|
|
1461
|
-
reasons: reasons.sort((left, right) => compareText(
|
|
1462
|
-
`${left.code}\0${left.detail}`, `${right.code}\0${right.detail}`,
|
|
1463
|
-
)),
|
|
1464
|
-
unknowns: unknowns.sort((left, right) => compareText(
|
|
1465
|
-
`${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
|
|
1466
|
-
)),
|
|
1467
|
-
};
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
/**
|
|
1471
|
-
* Low-level evaluator for already-materialized skeletons. Task assignment must have been bound
|
|
1472
|
-
* before this point; graph edges are never accepted as ownership evidence.
|
|
1473
|
-
*/
|
|
1474
|
-
export function evaluateSeamProposalCandidates({
|
|
1475
|
-
component,
|
|
1476
|
-
request,
|
|
1477
|
-
sensorEvidence,
|
|
1478
|
-
evidence,
|
|
1479
|
-
candidateSpecs,
|
|
1480
|
-
explorationComplete = false,
|
|
1481
|
-
concernAnchors = new Map(),
|
|
1482
|
-
} = {}) {
|
|
1483
|
-
if (!plainRecord(component)
|
|
1484
|
-
|| !Array.isArray(component.task_ids)
|
|
1485
|
-
|| !Array.isArray(component.conflicts)
|
|
1486
|
-
|| !Array.isArray(candidateSpecs)
|
|
1487
|
-
|| !plainRecord(evidence)) {
|
|
1488
|
-
fail('decision input shapeが不正');
|
|
1489
|
-
}
|
|
1490
|
-
const taskIds = [...component.task_ids].sort(compareText);
|
|
1491
|
-
if (taskIds.some((taskId, index) => taskId !== component.task_ids[index])) {
|
|
1492
|
-
fail('component.task_idsがstrict sortされていない');
|
|
1493
|
-
}
|
|
1494
|
-
const taskSet = new Set(taskIds);
|
|
1495
|
-
const intentAnchors = uniqueIntentAnchors(request.manual_witness, taskIds);
|
|
1496
|
-
const missingIntent = taskIds.filter((taskId) => (
|
|
1497
|
-
intentAnchors.get(taskId).length === 0 && (concernAnchors.get(taskId) ?? []).length === 0
|
|
1498
|
-
));
|
|
1499
|
-
if (missingIntent.length > 0) {
|
|
1500
|
-
return decisionUnknown(component, missingIntent.map((taskId) => ({
|
|
1501
|
-
kind: 'semantic_owner_binding_missing',
|
|
1502
|
-
ref: taskId,
|
|
1503
|
-
})));
|
|
1504
|
-
}
|
|
1505
|
-
if (candidateSpecs.length === 0) {
|
|
1506
|
-
return decisionUnknown(component, [{
|
|
1507
|
-
kind: 'candidate_exploration_incomplete',
|
|
1508
|
-
ref: component.component_id,
|
|
1509
|
-
}]);
|
|
1510
|
-
}
|
|
1511
|
-
const current = currentSurfaces(component, evidence);
|
|
1512
|
-
if (current === null) {
|
|
1513
|
-
return decisionUnknown(component, [{
|
|
1514
|
-
kind: 'exact_surface_evidence_missing',
|
|
1515
|
-
ref: component.component_id,
|
|
1516
|
-
}]);
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
const evaluated = [];
|
|
1520
|
-
const rejectedUnknowns = [];
|
|
1521
|
-
const candidateIds = candidateSpecs.map(({ candidate_id: candidateId }) => candidateId);
|
|
1522
|
-
if (new Set(candidateIds).size !== candidateIds.length) {
|
|
1523
|
-
fail('candidate_idが重複している');
|
|
1524
|
-
}
|
|
1525
|
-
for (const spec of candidateSpecs) {
|
|
1526
|
-
if (!exactRecord(spec, [
|
|
1527
|
-
'candidate_id', 'ownership_diff', 'proposed_surfaces',
|
|
1528
|
-
'surface_hypotheses', 'raw_graph',
|
|
1529
|
-
])) {
|
|
1530
|
-
fail('candidate specがclosed shapeではない');
|
|
1531
|
-
}
|
|
1532
|
-
const graphSurfaces = rawGraphSurfaceSet(spec.raw_graph);
|
|
1533
|
-
if (graphSurfaces === null) {
|
|
1534
|
-
rejectedUnknowns.push({ kind: 'raw_graph_unavailable', ref: spec.candidate_id });
|
|
1535
|
-
continue;
|
|
1536
|
-
}
|
|
1537
|
-
if (current.some((surface) => !graphSurfaces.has(
|
|
1538
|
-
`${surface.kind}\0${surface.target}\0${surface.path}`,
|
|
1539
|
-
))) {
|
|
1540
|
-
rejectedUnknowns.push({ kind: 'raw_graph_incomplete', ref: spec.candidate_id });
|
|
1541
|
-
continue;
|
|
1542
|
-
}
|
|
1543
|
-
if (!Array.isArray(spec.proposed_surfaces)
|
|
1544
|
-
|| spec.proposed_surfaces.some((surface) => !plainRecord(surface))) {
|
|
1545
|
-
fail('candidate proposed_surfacesが不正');
|
|
1546
|
-
}
|
|
1547
|
-
const hypothesisKeys = new Set(spec.surface_hypotheses.map((entry) => (
|
|
1548
|
-
`${entry.kind}\0${entry.target}\0${entry.path}`
|
|
1549
|
-
)));
|
|
1550
|
-
const graphIncomplete = spec.proposed_surfaces.some((surface) => {
|
|
1551
|
-
const key = `${surface.kind}\0${surface.target}\0${surface.path}`;
|
|
1552
|
-
return !graphSurfaces.has(key) && !hypothesisKeys.has(key);
|
|
1553
|
-
});
|
|
1554
|
-
if (graphIncomplete) {
|
|
1555
|
-
rejectedUnknowns.push({ kind: 'new_surface_assumption_missing', ref: spec.candidate_id });
|
|
1556
|
-
continue;
|
|
1557
|
-
}
|
|
1558
|
-
const diffIds = spec.ownership_diff.map(({ todo_id: todoId }) => todoId);
|
|
1559
|
-
if (diffIds.length !== taskIds.length
|
|
1560
|
-
|| new Set(diffIds).size !== taskIds.length
|
|
1561
|
-
|| diffIds.some((taskId) => !taskSet.has(taskId))) {
|
|
1562
|
-
fail('candidate ownership_diffがcomponent全taskのfull diffではない');
|
|
1563
|
-
}
|
|
1564
|
-
const virtualWitness = buildVirtualWitness({
|
|
1565
|
-
request,
|
|
1566
|
-
ownershipDiff: spec.ownership_diff,
|
|
1567
|
-
});
|
|
1568
|
-
const proposedOwnershipMismatch = spec.proposed_surfaces.some((surface) => {
|
|
1569
|
-
if (!Array.isArray(surface.owner_task_ids)) return true;
|
|
1570
|
-
const owners = taskIds.filter((taskId) => virtualWitness[taskId].owns
|
|
1571
|
-
.some((own) => resourceKey(own) === `${surface.kind}\0${surface.target}`));
|
|
1572
|
-
return owners.length !== surface.owner_task_ids.length
|
|
1573
|
-
|| owners.some((taskId, index) => taskId !== surface.owner_task_ids[index]);
|
|
1574
|
-
});
|
|
1575
|
-
if (proposedOwnershipMismatch) {
|
|
1576
|
-
rejectedUnknowns.push({
|
|
1577
|
-
kind: 'virtual_witness_surface_mismatch',
|
|
1578
|
-
ref: spec.candidate_id,
|
|
1579
|
-
});
|
|
1580
|
-
continue;
|
|
1581
|
-
}
|
|
1582
|
-
const receipt = createVirtualCompileReceipt({
|
|
1583
|
-
request,
|
|
1584
|
-
sensorEvidence,
|
|
1585
|
-
virtualWitness,
|
|
1586
|
-
surfaceHypotheses: spec.surface_hypotheses,
|
|
1587
|
-
});
|
|
1588
|
-
const derivation = receipt.derivation;
|
|
1589
|
-
if (derivation.outcome !== 'derived'
|
|
1590
|
-
|| derivation.unknowns.length > 0
|
|
1591
|
-
|| derivation.conflicts.length > 0) {
|
|
1592
|
-
const kind = derivation.drift.length > 0
|
|
1593
|
-
? derivation.drift[0].kind
|
|
1594
|
-
: derivation.unknowns.length > 0 ? 'virtual_boundary_unknown' : 'residual_conflict';
|
|
1595
|
-
rejectedUnknowns.push({ kind, ref: spec.candidate_id });
|
|
1596
|
-
evaluated.push({ spec, derivation, feasible: false });
|
|
1597
|
-
continue;
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
const proposed = structuredClone(spec.proposed_surfaces)
|
|
1601
|
-
.sort((left, right) => compareText(surfaceKey(left), surfaceKey(right)));
|
|
1602
|
-
const affectedTests = sortedUnique(taskIds.flatMap(
|
|
1603
|
-
(taskId) => virtualWitness[taskId].affected_tests,
|
|
1604
|
-
));
|
|
1605
|
-
const limits = spec.surface_hypotheses.length > 0
|
|
1606
|
-
? ['hypothetical_new_surfaces', 'structural_only']
|
|
1607
|
-
: ['structural_only'];
|
|
1608
|
-
const candidate = {
|
|
1609
|
-
proposal_id: deriveSeamProposalId({
|
|
1610
|
-
conflicts: component.conflicts,
|
|
1611
|
-
proposed_surfaces: proposed,
|
|
1612
|
-
}),
|
|
1613
|
-
current_surfaces: current,
|
|
1614
|
-
proposed_surfaces: proposed,
|
|
1615
|
-
affected_tests: affectedTests,
|
|
1616
|
-
verification: {
|
|
1617
|
-
virtual_compile_input_digest: receipt.verification.virtual_compile_input_digest,
|
|
1618
|
-
virtual_compile_result_digest: receipt.verification.virtual_compile_result_digest,
|
|
1619
|
-
residual_conflicts: [],
|
|
1620
|
-
},
|
|
1621
|
-
evidence: structuredClone(evidence),
|
|
1622
|
-
limits,
|
|
1623
|
-
proposal_digest: '',
|
|
1624
|
-
};
|
|
1625
|
-
candidate.proposal_digest = todoSelfDigest(candidate, 'proposal_digest');
|
|
1626
|
-
const changedSurfaces = new Set(proposed.map(surfaceKey));
|
|
1627
|
-
const blastRadius = new Set([
|
|
1628
|
-
...proposed.map(({ path }) => `path:${path}`),
|
|
1629
|
-
...affectedTests.map((path) => `test:${path}`),
|
|
1630
|
-
]);
|
|
1631
|
-
evaluated.push({
|
|
1632
|
-
spec,
|
|
1633
|
-
derivation,
|
|
1634
|
-
feasible: true,
|
|
1635
|
-
candidate,
|
|
1636
|
-
minimumWaves: Math.ceil(taskIds.length / request.capacity.executors),
|
|
1637
|
-
changedSurfaces,
|
|
1638
|
-
blastRadius,
|
|
1639
|
-
});
|
|
1640
|
-
}
|
|
1641
|
-
|
|
1642
|
-
const feasible = evaluated.filter(({ feasible }) => feasible);
|
|
1643
|
-
const nonDominated = feasible.filter((candidate, index) => (
|
|
1644
|
-
!feasible.some((other, otherIndex) => (
|
|
1645
|
-
index !== otherIndex && candidateDominates(other, candidate)
|
|
1646
|
-
))
|
|
1647
|
-
));
|
|
1648
|
-
if (nonDominated.length === 1) {
|
|
1649
|
-
return {
|
|
1650
|
-
component_id: component.component_id,
|
|
1651
|
-
task_ids: taskIds,
|
|
1652
|
-
conflicts: structuredClone(component.conflicts),
|
|
1653
|
-
verdict: 'seam_candidate',
|
|
1654
|
-
seam_candidate: nonDominated[0].candidate,
|
|
1655
|
-
reasons: [{
|
|
1656
|
-
code: 'unique_structural_dominant_candidate',
|
|
1657
|
-
detail: 'One feasible candidate structurally dominates all alternatives.',
|
|
1658
|
-
}],
|
|
1659
|
-
unknowns: [],
|
|
1660
|
-
};
|
|
1661
|
-
}
|
|
1662
|
-
if (nonDominated.length > 1) {
|
|
1663
|
-
return decisionUnknown(component, [{
|
|
1664
|
-
kind: 'multiple_incomparable_candidates',
|
|
1665
|
-
ref: nonDominated.map(({ spec }) => spec.candidate_id).sort(compareText).join(','),
|
|
1666
|
-
}]);
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
|
-
const serialKinds = new Set(['state', 'effect']);
|
|
1670
|
-
const unseverable = component.conflicts.filter(({ kind }) => serialKinds.has(kind));
|
|
1671
|
-
const unseverableRemains = unseverable.length > 0
|
|
1672
|
-
&& evaluated.length === candidateSpecs.length
|
|
1673
|
-
&& evaluated.every(({ derivation }) => unseverable.some((conflict) => (
|
|
1674
|
-
derivation.conflicts.some(({ resource_id: resourceId }) => (
|
|
1675
|
-
resourceId === conflict.resource_id || resourceId === conflict.target
|
|
1676
|
-
))
|
|
1677
|
-
)));
|
|
1678
|
-
if (explorationComplete && unseverableRemains) {
|
|
1679
|
-
return {
|
|
1680
|
-
component_id: component.component_id,
|
|
1681
|
-
task_ids: taskIds,
|
|
1682
|
-
conflicts: structuredClone(component.conflicts),
|
|
1683
|
-
verdict: 'intentional_serial',
|
|
1684
|
-
seam_candidate: null,
|
|
1685
|
-
reasons: [{
|
|
1686
|
-
code: 'unseverable_state_effect_conflict',
|
|
1687
|
-
detail: 'Complete exploration retained a current-contract state/effect conflict.',
|
|
1688
|
-
}],
|
|
1689
|
-
unknowns: [],
|
|
1690
|
-
};
|
|
1691
|
-
}
|
|
1692
|
-
const unknowns = rejectedUnknowns.length > 0
|
|
1693
|
-
? rejectedUnknowns
|
|
1694
|
-
: [{ kind: 'candidate_exploration_incomplete', ref: component.component_id }];
|
|
1695
|
-
if (!explorationComplete) {
|
|
1696
|
-
unknowns.push({ kind: 'candidate_exploration_incomplete', ref: component.component_id });
|
|
1697
|
-
}
|
|
1698
|
-
return decisionUnknown(component, unknowns);
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
function extractionPath(rootPath, skeletonId, taskId) {
|
|
1702
|
-
const slash = rootPath.lastIndexOf('/');
|
|
1703
|
-
const directory = slash === -1 ? '' : rootPath.slice(0, slash + 1);
|
|
1704
|
-
const filename = slash === -1 ? rootPath : rootPath.slice(slash + 1);
|
|
1705
|
-
const dot = filename.lastIndexOf('.');
|
|
1706
|
-
const stem = dot <= 0 ? filename : filename.slice(0, dot);
|
|
1707
|
-
const extension = dot <= 0 ? '.mjs' : filename.slice(dot);
|
|
1708
|
-
return `${directory}${stem}.seam-${sha16(`${skeletonId}\0${taskId}`)}${extension}`;
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
function skeletonCandidateSpec({ skeleton, component, request, evidence }) {
|
|
1712
|
-
const taskIds = [...component.task_ids].sort(compareText);
|
|
1713
|
-
if (skeleton.binding_unknowns.length > 0
|
|
1714
|
-
|| skeleton.task_bindings.length !== taskIds.length) return null;
|
|
1715
|
-
const current = currentSurfaces(component, evidence);
|
|
1716
|
-
if (current === null) return null;
|
|
1717
|
-
const conflictKeys = new Set(component.conflicts.map(({ kind, target }) => (
|
|
1718
|
-
`${kind}\0${target}`
|
|
1719
|
-
)));
|
|
1720
|
-
const currentPaths = sortedUnique(current.map(({ path }) => path));
|
|
1721
|
-
const ownershipDiff = [];
|
|
1722
|
-
const proposedSurfaces = [];
|
|
1723
|
-
const surfaceHypotheses = [];
|
|
1724
|
-
for (const taskId of taskIds) {
|
|
1725
|
-
const original = request.manual_witness[taskId];
|
|
1726
|
-
const path = extractionPath(skeleton.root_surface.path, skeleton.skeleton_id, taskId);
|
|
1727
|
-
const replacePath = (value) => (
|
|
1728
|
-
currentPaths.some((currentPath) => pathPrefixOverlap(value, currentPath)) ? path : value
|
|
1729
|
-
);
|
|
1730
|
-
const owns = original.owns.filter((own) => (
|
|
1731
|
-
!conflictKeys.has(resourceKey(own))
|
|
1732
|
-
&& !(own.kind === 'path' && currentPaths.includes(own.target))
|
|
1733
|
-
));
|
|
1734
|
-
owns.push({ kind: 'path', target: path });
|
|
1735
|
-
const sensorQueries = original.sensor_provenance.queries.filter(({ expect }) => {
|
|
1736
|
-
if (expect.kind === 'symbol') {
|
|
1737
|
-
return !component.conflicts.some((conflict) => (
|
|
1738
|
-
conflict.kind === 'symbol' && conflict.target === expect.name
|
|
1739
|
-
));
|
|
1740
|
-
}
|
|
1741
|
-
return !currentPaths.includes(expect.path);
|
|
1742
|
-
});
|
|
1743
|
-
ownershipDiff.push({
|
|
1744
|
-
todo_id: taskId,
|
|
1745
|
-
owns: owns.sort((left, right) => compareText(resourceKey(left), resourceKey(right))),
|
|
1746
|
-
reads: sortedUnique(original.reads.map(replacePath)),
|
|
1747
|
-
writes: sortedUnique([...original.writes.map(replacePath), path]),
|
|
1748
|
-
resources: structuredClone(original.resources),
|
|
1749
|
-
state_effects: structuredClone(original.state_effects),
|
|
1750
|
-
sensor_provenance: { queries: structuredClone(sensorQueries) },
|
|
1751
|
-
affected_tests: structuredClone(original.affected_tests),
|
|
1752
|
-
unknowns: structuredClone(original.unknowns),
|
|
1753
|
-
});
|
|
1754
|
-
proposedSurfaces.push({
|
|
1755
|
-
kind: 'path',
|
|
1756
|
-
target: path,
|
|
1757
|
-
path,
|
|
1758
|
-
role: 'task_owned',
|
|
1759
|
-
owner_task_ids: [taskId],
|
|
1760
|
-
});
|
|
1761
|
-
surfaceHypotheses.push({
|
|
1762
|
-
kind: 'path',
|
|
1763
|
-
target: path,
|
|
1764
|
-
path,
|
|
1765
|
-
owner_task_id: taskId,
|
|
1766
|
-
affected_tests: structuredClone(original.affected_tests),
|
|
1767
|
-
provenance: HYPOTHESIS_PROVENANCE,
|
|
1768
|
-
});
|
|
1769
|
-
}
|
|
1770
|
-
return {
|
|
1771
|
-
candidate_id: skeleton.skeleton_id,
|
|
1772
|
-
ownership_diff: ownershipDiff,
|
|
1773
|
-
proposed_surfaces: proposedSurfaces,
|
|
1774
|
-
surface_hypotheses: surfaceHypotheses,
|
|
1775
|
-
raw_graph: structuredClone(skeleton.raw_graph),
|
|
1776
|
-
};
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
/**
|
|
1780
|
-
* Enumerate, bind, materialize, and validate structural cut skeletons. Callers provide collected
|
|
1781
|
-
* sensor outcomes, never handwritten candidate specs.
|
|
1782
|
-
*/
|
|
1783
|
-
export function compileSeamProposalDecision({
|
|
1784
|
-
component,
|
|
1785
|
-
request,
|
|
1786
|
-
sensorEvidence,
|
|
1787
|
-
evidence,
|
|
1788
|
-
rawCollected,
|
|
1789
|
-
concernAnchors = new Map(),
|
|
1790
|
-
concernUnknowns = [],
|
|
1791
|
-
} = {}) {
|
|
1792
|
-
const enumeration = enumerateCutSkeletons({
|
|
1793
|
-
component,
|
|
1794
|
-
request,
|
|
1795
|
-
evidence,
|
|
1796
|
-
rawCollected,
|
|
1797
|
-
concernAnchors,
|
|
1798
|
-
});
|
|
1799
|
-
// 宣言が解決しなかった事実は、束縛が成功したかに関わらず記録から消さない。
|
|
1800
|
-
const unknowns = [...concernUnknowns, ...enumeration.unknowns];
|
|
1801
|
-
if (unknowns.length > 0) {
|
|
1802
|
-
return decisionUnknown(component, unknowns);
|
|
1803
|
-
}
|
|
1804
|
-
const candidateSpecs = enumeration.skeletons.map((skeleton) => (
|
|
1805
|
-
skeletonCandidateSpec({ skeleton, component, request, evidence })
|
|
1806
|
-
));
|
|
1807
|
-
if (candidateSpecs.some((spec) => spec === null)) {
|
|
1808
|
-
return decisionUnknown(component, [{
|
|
1809
|
-
kind: 'new_surface_assumption_missing',
|
|
1810
|
-
ref: component.component_id,
|
|
1811
|
-
}]);
|
|
1812
|
-
}
|
|
1813
|
-
return evaluateSeamProposalCandidates({
|
|
1814
|
-
component,
|
|
1815
|
-
request,
|
|
1816
|
-
sensorEvidence,
|
|
1817
|
-
evidence,
|
|
1818
|
-
candidateSpecs,
|
|
1819
|
-
explorationComplete: enumeration.exploration_complete,
|
|
1820
|
-
concernAnchors,
|
|
1821
|
-
});
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
export class SeamProposalCompileError extends Error {
|
|
1825
|
-
constructor(code, reason, detail = {}) {
|
|
1826
|
-
super(reason);
|
|
1827
|
-
this.name = 'SeamProposalCompileError';
|
|
1828
|
-
this.code = code;
|
|
1829
|
-
this.detail = { reason, ...detail };
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
|
|
1833
|
-
function compileFail(code, reason, detail) {
|
|
1834
|
-
throw new SeamProposalCompileError(code, reason, detail);
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
function conflictComponents(independenceArtifact) {
|
|
1838
|
-
const resourceById = new Map(independenceArtifact.conflict_resources.map((resource) => (
|
|
1839
|
-
[resource.resource_id, resource]
|
|
1840
|
-
)));
|
|
1841
|
-
const parent = new Map();
|
|
1842
|
-
const find = (taskId) => {
|
|
1843
|
-
const current = parent.get(taskId) ?? taskId;
|
|
1844
|
-
if (!parent.has(taskId)) parent.set(taskId, taskId);
|
|
1845
|
-
if (current === taskId) return taskId;
|
|
1846
|
-
const root = find(current);
|
|
1847
|
-
parent.set(taskId, root);
|
|
1848
|
-
return root;
|
|
1849
|
-
};
|
|
1850
|
-
const union = (left, right) => {
|
|
1851
|
-
const leftRoot = find(left);
|
|
1852
|
-
const rightRoot = find(right);
|
|
1853
|
-
if (leftRoot === rightRoot) return;
|
|
1854
|
-
if (compareText(leftRoot, rightRoot) < 0) parent.set(rightRoot, leftRoot);
|
|
1855
|
-
else parent.set(leftRoot, rightRoot);
|
|
1856
|
-
};
|
|
1857
|
-
|
|
1858
|
-
for (const { task_ids: [left, right] } of independenceArtifact.conflicts) {
|
|
1859
|
-
union(left, right);
|
|
1860
|
-
}
|
|
1861
|
-
|
|
1862
|
-
const conflictsByRoot = new Map();
|
|
1863
|
-
for (const conflict of independenceArtifact.conflicts) {
|
|
1864
|
-
const root = find(conflict.task_ids[0]);
|
|
1865
|
-
const entries = conflictsByRoot.get(root) ?? [];
|
|
1866
|
-
entries.push(conflict);
|
|
1867
|
-
conflictsByRoot.set(root, entries);
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
const components = [];
|
|
1871
|
-
const classifiedPairKeys = new Set();
|
|
1872
|
-
const classifiedResourceIds = new Set();
|
|
1873
|
-
for (const entries of conflictsByRoot.values()) {
|
|
1874
|
-
const taskIds = sortedUnique(entries.flatMap(({ task_ids: taskPair }) => taskPair));
|
|
1875
|
-
const pairsByResource = new Map();
|
|
1876
|
-
for (const entry of entries) {
|
|
1877
|
-
const pairs = pairsByResource.get(entry.resource_id) ?? [];
|
|
1878
|
-
pairs.push([...entry.task_ids]);
|
|
1879
|
-
pairsByResource.set(entry.resource_id, pairs);
|
|
1880
|
-
classifiedPairKeys.add(`${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`);
|
|
1881
|
-
}
|
|
1882
|
-
const conflicts = [...pairsByResource].map(([resourceId, pairs]) => {
|
|
1883
|
-
const resource = resourceById.get(resourceId);
|
|
1884
|
-
if (resource === undefined) {
|
|
1885
|
-
compileFail('SEAM_PROPOSAL_COMPONENT_INVALID', 'conflict_resource_missing', {
|
|
1886
|
-
resource_id: resourceId,
|
|
1887
|
-
});
|
|
1888
|
-
}
|
|
1889
|
-
if (classifiedResourceIds.has(resourceId)) {
|
|
1890
|
-
compileFail('SEAM_PROPOSAL_COMPONENT_INVALID', 'conflict_resource_classified_twice', {
|
|
1891
|
-
resource_id: resourceId,
|
|
1892
|
-
});
|
|
1893
|
-
}
|
|
1894
|
-
classifiedResourceIds.add(resourceId);
|
|
1895
|
-
return {
|
|
1896
|
-
...structuredClone(resource),
|
|
1897
|
-
task_pairs: pairs.sort((left, right) => compareText(
|
|
1898
|
-
`${left[0]}\0${left[1]}`, `${right[0]}\0${right[1]}`,
|
|
1899
|
-
)),
|
|
1900
|
-
};
|
|
1901
|
-
}).sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
1902
|
-
const identity = { task_ids: taskIds, conflicts };
|
|
1903
|
-
components.push({
|
|
1904
|
-
component_id: `component-${digestTodoArtifact(identity).slice(0, 24)}`,
|
|
1905
|
-
...identity,
|
|
1906
|
-
});
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
const expectedPairKeys = new Set(independenceArtifact.conflicts.map((entry) => (
|
|
1910
|
-
`${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`
|
|
1911
|
-
)));
|
|
1912
|
-
if (classifiedPairKeys.size !== expectedPairKeys.size
|
|
1913
|
-
|| [...expectedPairKeys].some((key) => !classifiedPairKeys.has(key))
|
|
1914
|
-
|| classifiedResourceIds.size !== independenceArtifact.conflict_resources.length) {
|
|
1915
|
-
compileFail('SEAM_PROPOSAL_COMPONENT_INVALID', 'conflict_component_partition_incomplete', {
|
|
1916
|
-
expected_pair_count: expectedPairKeys.size,
|
|
1917
|
-
classified_pair_count: classifiedPairKeys.size,
|
|
1918
|
-
expected_resource_count: independenceArtifact.conflict_resources.length,
|
|
1919
|
-
classified_resource_count: classifiedResourceIds.size,
|
|
1920
|
-
});
|
|
1921
|
-
}
|
|
1922
|
-
return components.sort((left, right) => compareText(left.component_id, right.component_id));
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
/**
|
|
1926
|
-
* Build the immutable lattice.seam_proposal.v2 artifact from one complete independence record.
|
|
1927
|
-
* Sensor collection stays outside this producer; callers pass the original witness evidence and
|
|
1928
|
-
* the seam-specific normalized/raw evidence collected for the same clean HEAD.
|
|
1929
|
-
*/
|
|
1930
|
-
export function compileSeamProposalArtifact({
|
|
1931
|
-
independenceArtifact,
|
|
1932
|
-
witnessSet,
|
|
1933
|
-
plan,
|
|
1934
|
-
compiledAt,
|
|
1935
|
-
sensorEvidence,
|
|
1936
|
-
evidence,
|
|
1937
|
-
rawCollected,
|
|
1938
|
-
} = {}) {
|
|
1939
|
-
if (!validateTodoIndependence(independenceArtifact)) {
|
|
1940
|
-
compileFail('SEAM_PROPOSAL_INDEPENDENCE_INVALID', 'independence_artifact_invalid');
|
|
1941
|
-
}
|
|
1942
|
-
if (independenceArtifact.outcome !== 'compiled') {
|
|
1943
|
-
compileFail('SEAM_PROPOSAL_INDEPENDENCE_UNAVAILABLE', 'independence_outcome_not_compiled', {
|
|
1944
|
-
outcome: independenceArtifact.outcome,
|
|
1945
|
-
});
|
|
1946
|
-
}
|
|
1947
|
-
if (!validateTodoWitnessSet(witnessSet)) {
|
|
1948
|
-
compileFail('SEAM_PROPOSAL_WITNESS_INVALID', 'witness_set_invalid');
|
|
1949
|
-
}
|
|
1950
|
-
if (!validateTodoPlan(plan)) {
|
|
1951
|
-
compileFail('SEAM_PROPOSAL_PLAN_INVALID', 'plan_invalid');
|
|
1952
|
-
}
|
|
1953
|
-
if (independenceArtifact.project_id !== plan.project_id
|
|
1954
|
-
|| independenceArtifact.plan_key !== plan.plan_key
|
|
1955
|
-
|| independenceArtifact.plan_version !== plan.plan_version
|
|
1956
|
-
|| independenceArtifact.topology_digest !== plan.topology_digest) {
|
|
1957
|
-
compileFail('SEAM_PROPOSAL_BINDING_MISMATCH', 'independence_plan_mismatch');
|
|
1958
|
-
}
|
|
1959
|
-
if (witnessSet.project_id !== plan.project_id
|
|
1960
|
-
|| witnessSet.plan_key !== plan.plan_key
|
|
1961
|
-
|| witnessSet.witness_set_digest !== independenceArtifact.witness_set_digest) {
|
|
1962
|
-
compileFail('SEAM_PROPOSAL_BINDING_MISMATCH', 'witness_independence_mismatch');
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
const request = synthesizeWitnessRunRequest(witnessSet, {
|
|
1966
|
-
baseSha: independenceArtifact.base_sha,
|
|
1967
|
-
requestId: `seam-proposal-${independenceArtifact.result_digest.slice(0, 24)}`,
|
|
1968
|
-
});
|
|
1969
|
-
const components = conflictComponents(independenceArtifact);
|
|
1970
|
-
const concern = resolveConcernAnchors({
|
|
1971
|
-
manualWitness: witnessSet.manual_witness,
|
|
1972
|
-
taskIds: [...new Set(components.flatMap(({ task_ids: ids }) => ids))].sort(compareText),
|
|
1973
|
-
evidence,
|
|
1974
|
-
});
|
|
1975
|
-
const decisions = components.map((component) => (
|
|
1976
|
-
compileSeamProposalDecision({
|
|
1977
|
-
component,
|
|
1978
|
-
request,
|
|
1979
|
-
sensorEvidence,
|
|
1980
|
-
evidence,
|
|
1981
|
-
rawCollected,
|
|
1982
|
-
concernAnchors: concern.anchorsByTask,
|
|
1983
|
-
// このcomponentのtaskに関する解決失敗だけを持ち込む。
|
|
1984
|
-
concernUnknowns: concern.unknowns.filter((unknown) => (
|
|
1985
|
-
component.task_ids.some((taskId) => unknown.ref.startsWith(`${taskId}:`)
|
|
1986
|
-
|| unknown.ref.split(':')[0].split(',').includes(taskId))
|
|
1987
|
-
)),
|
|
1988
|
-
})
|
|
1989
|
-
)).sort((left, right) => compareText(left.component_id, right.component_id));
|
|
1990
|
-
const artifact = {
|
|
1991
|
-
schema: SEAM_PROPOSAL_SCHEMA,
|
|
1992
|
-
project_id: plan.project_id,
|
|
1993
|
-
plan_key: plan.plan_key,
|
|
1994
|
-
source_binding: {
|
|
1995
|
-
independence_schema: independenceArtifact.schema,
|
|
1996
|
-
independence_result_digest: independenceArtifact.result_digest,
|
|
1997
|
-
witness_set_digest: independenceArtifact.witness_set_digest,
|
|
1998
|
-
plan_version: independenceArtifact.plan_version,
|
|
1999
|
-
topology_digest: independenceArtifact.topology_digest,
|
|
2000
|
-
base_sha: independenceArtifact.base_sha,
|
|
2001
|
-
},
|
|
2002
|
-
compiled_at: compiledAt,
|
|
2003
|
-
decisions,
|
|
2004
|
-
result_digest: '',
|
|
2005
|
-
};
|
|
2006
|
-
artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
|
|
2007
|
-
if (!validateSeamProposal(artifact)) {
|
|
2008
|
-
compileFail('SEAM_PROPOSAL_ARTIFACT_INVALID', 'seam_proposal_artifact_invalid');
|
|
2009
|
-
}
|
|
2010
|
-
return artifact;
|
|
2011
|
-
}
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { digestArtifact } from './artifact-contracts.mjs';
|
|
4
|
+
import { portableSensorOutcome } from './sensor-adapter.mjs';
|
|
5
|
+
import {
|
|
6
|
+
SEAM_PROPOSAL_SCHEMA,
|
|
7
|
+
deriveSeamProposalId,
|
|
8
|
+
validateSeamProposal,
|
|
9
|
+
} from './seam-proposal-contracts.mjs';
|
|
10
|
+
import {
|
|
11
|
+
synthesizeWitnessRunRequest,
|
|
12
|
+
validateTodoIndependence,
|
|
13
|
+
validateTodoWitnessSet,
|
|
14
|
+
} from './todo-independence-contracts.mjs';
|
|
15
|
+
import {
|
|
16
|
+
digestTodoArtifact,
|
|
17
|
+
todoSelfDigest,
|
|
18
|
+
validateTodoPlan,
|
|
19
|
+
} from './todo-contracts.mjs';
|
|
20
|
+
import {
|
|
21
|
+
selfDigest,
|
|
22
|
+
validateRunRequest,
|
|
23
|
+
} from './runtime-contracts.mjs';
|
|
24
|
+
|
|
25
|
+
const WITNESS_FIELDS = Object.freeze([
|
|
26
|
+
'owns',
|
|
27
|
+
'reads',
|
|
28
|
+
'writes',
|
|
29
|
+
'resources',
|
|
30
|
+
'state_effects',
|
|
31
|
+
'sensor_provenance',
|
|
32
|
+
'affected_tests',
|
|
33
|
+
'unknowns',
|
|
34
|
+
]);
|
|
35
|
+
const STATE_KIND_MAP = Object.freeze({
|
|
36
|
+
state: 'state',
|
|
37
|
+
schema: 'state',
|
|
38
|
+
invariant: 'state',
|
|
39
|
+
effect: 'effect',
|
|
40
|
+
external_effect: 'effect',
|
|
41
|
+
});
|
|
42
|
+
const STRUCTURE_OPERATIONS = new Set(['query', 'callers', 'callees', 'impact']);
|
|
43
|
+
const HYPOTHESIS_PROVENANCE = 'extraction_hypothesis';
|
|
44
|
+
|
|
45
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
46
|
+
|
|
47
|
+
function fail(reason) {
|
|
48
|
+
throw new TypeError(`seam proposal producer契約違反: ${reason}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function plainRecord(value) {
|
|
52
|
+
return value !== null
|
|
53
|
+
&& typeof value === 'object'
|
|
54
|
+
&& !Array.isArray(value)
|
|
55
|
+
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function exactRecord(value, keys) {
|
|
59
|
+
if (!plainRecord(value)) return false;
|
|
60
|
+
const actual = Object.keys(value).sort(compareText);
|
|
61
|
+
const expected = [...keys].sort(compareText);
|
|
62
|
+
return actual.length === expected.length
|
|
63
|
+
&& actual.every((key, index) => key === expected[index]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sha16(value) {
|
|
67
|
+
return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 16);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resourceKey(value) {
|
|
71
|
+
return `${value.kind}\0${value.target}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function surfaceKey(value) {
|
|
75
|
+
return `${value.kind}\0${value.target}\0${value.path}\0${value.role}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sortedUnique(values) {
|
|
79
|
+
return [...new Set(values)].sort(compareText);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isSubset(left, right) {
|
|
83
|
+
return [...left].every((value) => right.has(value));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pathPrefixOverlap(left, right) {
|
|
87
|
+
const leftIsPrefix = left.endsWith('/');
|
|
88
|
+
const rightIsPrefix = right.endsWith('/');
|
|
89
|
+
if (left === right) return true;
|
|
90
|
+
if (leftIsPrefix && (right === left.slice(0, -1) || right.startsWith(left))) return true;
|
|
91
|
+
if (rightIsPrefix && (left === right.slice(0, -1) || left.startsWith(right))) return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function withWitness(request, manualWitness) {
|
|
96
|
+
const virtualRequest = structuredClone(request);
|
|
97
|
+
virtualRequest.manual_witness = structuredClone(manualWitness);
|
|
98
|
+
virtualRequest.request_digest = '';
|
|
99
|
+
virtualRequest.request_digest = selfDigest(virtualRequest, 'request_digest');
|
|
100
|
+
if (!validateRunRequest(virtualRequest)) fail('virtual witnessがrun_request.v1を満たさない');
|
|
101
|
+
return virtualRequest;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Clone the original witness and apply a closed, full-field ownership diff.
|
|
106
|
+
* A task patch containing only `owns` is deliberately rejected.
|
|
107
|
+
*/
|
|
108
|
+
export function buildVirtualWitness({ request, ownershipDiff } = {}) {
|
|
109
|
+
if (!validateRunRequest(request)) fail('requestがrun_request.v1を満たさない');
|
|
110
|
+
if (!Array.isArray(ownershipDiff)) fail('ownershipDiffがarrayではない');
|
|
111
|
+
const taskIds = new Set(request.todos.map(({ todo_id: todoId }) => todoId));
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
const virtualWitness = structuredClone(request.manual_witness);
|
|
114
|
+
for (const patch of ownershipDiff) {
|
|
115
|
+
if (!exactRecord(patch, ['todo_id', ...WITNESS_FIELDS])
|
|
116
|
+
|| !taskIds.has(patch.todo_id)
|
|
117
|
+
|| seen.has(patch.todo_id)) {
|
|
118
|
+
fail('ownershipDiff entryがclosed full-field shapeではない');
|
|
119
|
+
}
|
|
120
|
+
seen.add(patch.todo_id);
|
|
121
|
+
virtualWitness[patch.todo_id] = Object.fromEntries(
|
|
122
|
+
WITNESS_FIELDS.map((field) => [field, structuredClone(patch[field])]),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
withWitness(request, virtualWitness);
|
|
126
|
+
return virtualWitness;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeQueries(request) {
|
|
130
|
+
const byId = new Map();
|
|
131
|
+
for (const query of request.sensor_query_set.queries) {
|
|
132
|
+
if (byId.has(query.id)) fail(`query idが重複している: ${query.id}`);
|
|
133
|
+
byId.set(query.id, query);
|
|
134
|
+
}
|
|
135
|
+
return byId;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeEvidence(request, sensorEvidence) {
|
|
139
|
+
if (!exactRecord(sensorEvidence, ['outcomes'])
|
|
140
|
+
|| !Array.isArray(sensorEvidence.outcomes)
|
|
141
|
+
|| sensorEvidence.outcomes.length !== request.sensor_query_set.queries.length) {
|
|
142
|
+
fail('sensorEvidenceがquery setとexact整合しない');
|
|
143
|
+
}
|
|
144
|
+
const byId = new Map();
|
|
145
|
+
request.sensor_query_set.queries.forEach((query, index) => {
|
|
146
|
+
const outcome = sensorEvidence.outcomes[index];
|
|
147
|
+
if (!exactRecord(outcome, ['query_id', 'operation', 'status', 'raw'])
|
|
148
|
+
|| outcome.query_id !== query.id
|
|
149
|
+
|| outcome.operation !== query.operation
|
|
150
|
+
|| typeof outcome.status !== 'string'
|
|
151
|
+
|| outcome.status.length === 0) {
|
|
152
|
+
fail(`sensorEvidence outcomeがquery ${query.id}とexact整合しない`);
|
|
153
|
+
}
|
|
154
|
+
const portable = portableSensorOutcome(outcome.raw);
|
|
155
|
+
byId.set(query.id, {
|
|
156
|
+
...outcome,
|
|
157
|
+
portable_digest: digestArtifact({
|
|
158
|
+
query_id: outcome.query_id,
|
|
159
|
+
operation: outcome.operation,
|
|
160
|
+
status: outcome.status,
|
|
161
|
+
portable,
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return byId;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function bindingMatchesQuery(binding, query) {
|
|
169
|
+
if (query === undefined) return false;
|
|
170
|
+
const { expect } = binding;
|
|
171
|
+
if (expect.kind === 'affected') {
|
|
172
|
+
return query.operation === 'affected' && query.target === expect.path;
|
|
173
|
+
}
|
|
174
|
+
if (expect.kind === 'symbol') {
|
|
175
|
+
return STRUCTURE_OPERATIONS.has(query.operation) && query.target === expect.name;
|
|
176
|
+
}
|
|
177
|
+
return (STRUCTURE_OPERATIONS.has(query.operation) || query.operation === 'affected')
|
|
178
|
+
&& query.target === expect.path;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function rawPayload(raw) {
|
|
182
|
+
return plainRecord(raw) && Object.hasOwn(raw, 'data') ? raw.data : raw;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function affectedPayload(raw, expectPath) {
|
|
186
|
+
if (plainRecord(raw) && Array.isArray(raw.targets)) {
|
|
187
|
+
const entry = raw.targets.find((candidate) => (
|
|
188
|
+
plainRecord(candidate) && candidate.target === expectPath
|
|
189
|
+
));
|
|
190
|
+
return plainRecord(entry) && plainRecord(entry.data) ? entry.data : null;
|
|
191
|
+
}
|
|
192
|
+
const payload = rawPayload(raw);
|
|
193
|
+
return plainRecord(payload) ? payload : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function affectedTarget(raw, expectPath) {
|
|
197
|
+
if (!plainRecord(raw) || !Array.isArray(raw.targets)) return null;
|
|
198
|
+
const entry = raw.targets.find((candidate) => (
|
|
199
|
+
plainRecord(candidate) && candidate.target === expectPath
|
|
200
|
+
));
|
|
201
|
+
return plainRecord(entry) ? entry : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function entryNode(entry) {
|
|
205
|
+
if (plainRecord(entry) && plainRecord(entry.node)) return entry.node;
|
|
206
|
+
return plainRecord(entry) ? entry : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveBindingStatus(binding, outcome) {
|
|
210
|
+
if (outcome.status !== 'ready') return outcome.status;
|
|
211
|
+
const { expect } = binding;
|
|
212
|
+
if (expect.kind === 'affected') {
|
|
213
|
+
if (affectedTarget(outcome.raw, expect.path)?.path_state === 'absent') return 'path_absent';
|
|
214
|
+
const payload = affectedPayload(outcome.raw, expect.path);
|
|
215
|
+
if (payload === null
|
|
216
|
+
|| !Array.isArray(payload.changedFiles)
|
|
217
|
+
|| payload.changedFiles.length !== 1
|
|
218
|
+
|| payload.changedFiles[0] !== expect.path) {
|
|
219
|
+
return 'empty';
|
|
220
|
+
}
|
|
221
|
+
return 'ready';
|
|
222
|
+
}
|
|
223
|
+
const payload = rawPayload(outcome.raw);
|
|
224
|
+
if (!Array.isArray(payload)) return 'invalid_json';
|
|
225
|
+
if (expect.kind === 'symbol') {
|
|
226
|
+
const matches = payload.filter((entry) => {
|
|
227
|
+
const node = entryNode(entry);
|
|
228
|
+
return node !== null && node.name === expect.name && node.filePath === expect.path;
|
|
229
|
+
});
|
|
230
|
+
if (matches.length === 1) return 'ready';
|
|
231
|
+
return matches.length === 0 ? 'symbol_absent' : 'unresolved';
|
|
232
|
+
}
|
|
233
|
+
const matches = payload.filter((entry) => entryNode(entry)?.filePath === expect.path);
|
|
234
|
+
return matches.length >= 1 ? 'ready' : 'empty';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function bindingCoversOwn(binding, own) {
|
|
238
|
+
if (own.kind === 'symbol') {
|
|
239
|
+
return binding.expect.kind === 'symbol' && binding.expect.name === own.target;
|
|
240
|
+
}
|
|
241
|
+
return (binding.expect.kind === 'path' || binding.expect.kind === 'affected')
|
|
242
|
+
&& binding.expect.path === own.target;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeHypotheses(surfaceHypotheses, manualWitness) {
|
|
246
|
+
if (!Array.isArray(surfaceHypotheses)) fail('surfaceHypothesesがarrayではない');
|
|
247
|
+
const taskIds = new Set(Object.keys(manualWitness));
|
|
248
|
+
const hypotheses = surfaceHypotheses.map((entry) => {
|
|
249
|
+
if (!exactRecord(entry, [
|
|
250
|
+
'kind', 'target', 'path', 'owner_task_id', 'affected_tests', 'provenance',
|
|
251
|
+
])
|
|
252
|
+
|| !['symbol', 'path'].includes(entry.kind)
|
|
253
|
+
|| typeof entry.target !== 'string'
|
|
254
|
+
|| typeof entry.path !== 'string'
|
|
255
|
+
|| !taskIds.has(entry.owner_task_id)
|
|
256
|
+
|| !Array.isArray(entry.affected_tests)
|
|
257
|
+
|| entry.provenance !== HYPOTHESIS_PROVENANCE) {
|
|
258
|
+
fail('surface hypothesis shapeが不正');
|
|
259
|
+
}
|
|
260
|
+
if (entry.kind === 'path' && entry.target !== entry.path) {
|
|
261
|
+
fail('path hypothesisのtargetとpathが一致しない');
|
|
262
|
+
}
|
|
263
|
+
const witness = manualWitness[entry.owner_task_id];
|
|
264
|
+
if (!witness.owns.some((own) => resourceKey(own) === `${entry.kind}\0${entry.target}`)
|
|
265
|
+
|| !witness.writes.includes(entry.path)
|
|
266
|
+
|| entry.affected_tests.some((path) => !witness.affected_tests.includes(path))) {
|
|
267
|
+
fail('surface hypothesisが完全なvirtual witnessへ反映されていない');
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
...structuredClone(entry),
|
|
271
|
+
affected_tests: [...entry.affected_tests].sort(compareText),
|
|
272
|
+
};
|
|
273
|
+
}).sort((left, right) => compareText(
|
|
274
|
+
`${left.owner_task_id}\0${left.kind}\0${left.target}`,
|
|
275
|
+
`${right.owner_task_id}\0${right.kind}\0${right.target}`,
|
|
276
|
+
));
|
|
277
|
+
const keys = hypotheses.map((entry) => (
|
|
278
|
+
`${entry.owner_task_id}\0${entry.kind}\0${entry.target}`
|
|
279
|
+
));
|
|
280
|
+
if (new Set(keys).size !== keys.length) fail('surface hypothesisが重複している');
|
|
281
|
+
return hypotheses;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function hypothesisForOwn(hypotheses, todoId, own) {
|
|
285
|
+
return hypotheses.find((entry) => (
|
|
286
|
+
entry.owner_task_id === todoId
|
|
287
|
+
&& entry.kind === own.kind
|
|
288
|
+
&& entry.target === own.target
|
|
289
|
+
));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hypothesisForBinding(hypotheses, todoId, binding) {
|
|
293
|
+
return hypotheses.find((entry) => (
|
|
294
|
+
entry.owner_task_id === todoId
|
|
295
|
+
&& ((binding.expect.kind === 'symbol'
|
|
296
|
+
&& entry.kind === 'symbol'
|
|
297
|
+
&& entry.target === binding.expect.name
|
|
298
|
+
&& entry.path === binding.expect.path)
|
|
299
|
+
|| (['path', 'affected'].includes(binding.expect.kind)
|
|
300
|
+
&& entry.kind === 'path'
|
|
301
|
+
&& entry.path === binding.expect.path))
|
|
302
|
+
));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function manualProvenance(request) {
|
|
306
|
+
return {
|
|
307
|
+
source: 'manual_state_effect',
|
|
308
|
+
evidence_ref: `run-request:${request.request_id}`,
|
|
309
|
+
evidence_digest: request.request_digest,
|
|
310
|
+
status: 'asserted',
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function candidateProvenance(request) {
|
|
315
|
+
return {
|
|
316
|
+
source: 'manual_candidate_spec',
|
|
317
|
+
evidence_ref: `run-request:${request.request_id}`,
|
|
318
|
+
evidence_digest: request.request_digest,
|
|
319
|
+
status: 'asserted',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function derivationResult({
|
|
324
|
+
outcome,
|
|
325
|
+
virtualWitness,
|
|
326
|
+
hypotheses,
|
|
327
|
+
resources = [],
|
|
328
|
+
conflicts = [],
|
|
329
|
+
unknowns = [],
|
|
330
|
+
unresolvedWitnesses = [],
|
|
331
|
+
drift = [],
|
|
332
|
+
}) {
|
|
333
|
+
const result = {
|
|
334
|
+
outcome,
|
|
335
|
+
resources,
|
|
336
|
+
conflicts,
|
|
337
|
+
unknowns,
|
|
338
|
+
unresolved_witnesses: unresolvedWitnesses,
|
|
339
|
+
drift,
|
|
340
|
+
};
|
|
341
|
+
return {
|
|
342
|
+
virtual_witness: virtualWitness,
|
|
343
|
+
surface_hypotheses: hypotheses,
|
|
344
|
+
...result,
|
|
345
|
+
input_digest: digestArtifact({
|
|
346
|
+
virtual_witness: virtualWitness,
|
|
347
|
+
surface_hypotheses: hypotheses,
|
|
348
|
+
}),
|
|
349
|
+
result_digest: digestArtifact(result),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Pure duplicate of runtime-front-end's resource lowering. Existing surfaces retain real
|
|
355
|
+
* sensor provenance; absent/new surfaces can only be represented by an explicit extraction
|
|
356
|
+
* hypothesis and are never relabelled as observed sensor nodes.
|
|
357
|
+
*/
|
|
358
|
+
export function deriveVirtualBoundary({
|
|
359
|
+
request,
|
|
360
|
+
sensorEvidence,
|
|
361
|
+
virtualWitness = request?.manual_witness,
|
|
362
|
+
surfaceHypotheses = [],
|
|
363
|
+
} = {}) {
|
|
364
|
+
const virtualRequest = withWitness(request, virtualWitness);
|
|
365
|
+
const hypotheses = normalizeHypotheses(surfaceHypotheses, virtualRequest.manual_witness);
|
|
366
|
+
const queryById = normalizeQueries(virtualRequest);
|
|
367
|
+
const outcomeById = normalizeEvidence(virtualRequest, sensorEvidence);
|
|
368
|
+
const todoIds = virtualRequest.todos.map(({ todo_id: todoId }) => todoId);
|
|
369
|
+
const bindingsByTodo = new Map();
|
|
370
|
+
const drift = [];
|
|
371
|
+
|
|
372
|
+
for (const todoId of todoIds) {
|
|
373
|
+
const bindings = virtualRequest.manual_witness[todoId].sensor_provenance.queries;
|
|
374
|
+
bindingsByTodo.set(todoId, bindings);
|
|
375
|
+
for (const binding of bindings) {
|
|
376
|
+
if (!bindingMatchesQuery(binding, queryById.get(binding.query_id))) {
|
|
377
|
+
drift.push({
|
|
378
|
+
kind: 'query_drift',
|
|
379
|
+
todo_id: todoId,
|
|
380
|
+
ref: binding.query_id,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const statusQueries = [...queryById.values()]
|
|
386
|
+
.filter((query) => query.operation === 'status');
|
|
387
|
+
if (statusQueries.length !== 1) {
|
|
388
|
+
drift.push({ kind: 'query_drift', todo_id: todoIds[0], ref: 'status_query_count' });
|
|
389
|
+
}
|
|
390
|
+
if (drift.length > 0) {
|
|
391
|
+
return derivationResult({
|
|
392
|
+
outcome: 'query_drift',
|
|
393
|
+
virtualWitness: virtualRequest.manual_witness,
|
|
394
|
+
hypotheses,
|
|
395
|
+
drift: drift.sort((left, right) => compareText(
|
|
396
|
+
`${left.todo_id}\0${left.kind}\0${left.ref}`,
|
|
397
|
+
`${right.todo_id}\0${right.kind}\0${right.ref}`,
|
|
398
|
+
)),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const statusOutcome = outcomeById.get(statusQueries[0].id);
|
|
403
|
+
const unresolved = [];
|
|
404
|
+
if (statusOutcome.status !== 'ready') {
|
|
405
|
+
for (const todoId of todoIds) {
|
|
406
|
+
unresolved.push({ todo_id: todoId, kind: `sensor_${statusOutcome.status}`, ref: statusQueries[0].id });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
for (const todoId of todoIds) {
|
|
411
|
+
for (const binding of bindingsByTodo.get(todoId)) {
|
|
412
|
+
const status = resolveBindingStatus(binding, outcomeById.get(binding.query_id));
|
|
413
|
+
const hypothesis = hypothesisForBinding(hypotheses, todoId, binding);
|
|
414
|
+
const hypothesisMayReplaceAbsence = hypothesis !== undefined
|
|
415
|
+
&& ['symbol_absent', 'path_absent', 'empty'].includes(status);
|
|
416
|
+
if (status !== 'ready' && !hypothesisMayReplaceAbsence) {
|
|
417
|
+
unresolved.push({ todo_id: todoId, kind: `sensor_${status}`, ref: binding.query_id });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const affectedDrift = [];
|
|
423
|
+
for (const todoId of todoIds) {
|
|
424
|
+
const witness = virtualRequest.manual_witness[todoId];
|
|
425
|
+
for (const binding of bindingsByTodo.get(todoId)) {
|
|
426
|
+
if (binding.expect.kind !== 'affected') continue;
|
|
427
|
+
const outcome = outcomeById.get(binding.query_id);
|
|
428
|
+
if (resolveBindingStatus(binding, outcome) !== 'ready') continue;
|
|
429
|
+
const payload = affectedPayload(outcome.raw, binding.expect.path);
|
|
430
|
+
const observed = Array.isArray(payload?.affectedTests)
|
|
431
|
+
? [...payload.affectedTests].sort(compareText)
|
|
432
|
+
: null;
|
|
433
|
+
const declared = [...witness.affected_tests].sort(compareText);
|
|
434
|
+
if (observed === null
|
|
435
|
+
|| observed.length !== declared.length
|
|
436
|
+
|| observed.some((test, index) => test !== declared[index])) {
|
|
437
|
+
affectedDrift.push({
|
|
438
|
+
kind: 'affected_test_drift',
|
|
439
|
+
todo_id: todoId,
|
|
440
|
+
ref: binding.query_id,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (affectedDrift.length > 0) {
|
|
446
|
+
return derivationResult({
|
|
447
|
+
outcome: 'affected_test_drift',
|
|
448
|
+
virtualWitness: virtualRequest.manual_witness,
|
|
449
|
+
hypotheses,
|
|
450
|
+
drift: affectedDrift.sort((left, right) => compareText(
|
|
451
|
+
`${left.todo_id}\0${left.ref}`, `${right.todo_id}\0${right.ref}`,
|
|
452
|
+
)),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const ownGroups = new Map();
|
|
457
|
+
const coveringByTarget = new Map();
|
|
458
|
+
for (const todoId of todoIds) {
|
|
459
|
+
const witness = virtualRequest.manual_witness[todoId];
|
|
460
|
+
for (const own of witness.owns) {
|
|
461
|
+
const key = `${own.kind} ${own.target}`;
|
|
462
|
+
if (!ownGroups.has(key)) {
|
|
463
|
+
ownGroups.set(key, {
|
|
464
|
+
own,
|
|
465
|
+
todoIds: [],
|
|
466
|
+
hypotheticalTodoIds: new Set(),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const group = ownGroups.get(key);
|
|
470
|
+
group.todoIds.push(todoId);
|
|
471
|
+
const covering = bindingsByTodo.get(todoId).filter((binding) => bindingCoversOwn(binding, own));
|
|
472
|
+
for (const binding of covering) {
|
|
473
|
+
const seen = coveringByTarget.get(key);
|
|
474
|
+
if (seen === undefined) coveringByTarget.set(key, binding.query_id);
|
|
475
|
+
else if (seen !== binding.query_id) {
|
|
476
|
+
drift.push({ kind: 'query_drift', todo_id: todoId, ref: `${seen} ${binding.query_id}` });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const hypothesis = hypothesisForOwn(hypotheses, todoId, own);
|
|
480
|
+
if (hypothesis !== undefined) group.hypotheticalTodoIds.add(todoId);
|
|
481
|
+
if (covering.length === 0 && hypothesis === undefined) {
|
|
482
|
+
unresolved.push({ todo_id: todoId, kind: 'sensor_unbound', ref: `${own.kind}:${own.target}` });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (drift.length > 0) {
|
|
487
|
+
return derivationResult({
|
|
488
|
+
outcome: 'query_drift',
|
|
489
|
+
virtualWitness: virtualRequest.manual_witness,
|
|
490
|
+
hypotheses,
|
|
491
|
+
drift,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (let left = 0; left < todoIds.length; left += 1) {
|
|
496
|
+
for (let right = left + 1; right < todoIds.length; right += 1) {
|
|
497
|
+
const leftWitness = virtualRequest.manual_witness[todoIds[left]];
|
|
498
|
+
const rightWitness = virtualRequest.manual_witness[todoIds[right]];
|
|
499
|
+
const leftOwnPaths = new Set(leftWitness.owns
|
|
500
|
+
.filter((own) => own.kind === 'path').map((own) => own.target));
|
|
501
|
+
const rightOwnPaths = new Set(rightWitness.owns
|
|
502
|
+
.filter((own) => own.kind === 'path').map((own) => own.target));
|
|
503
|
+
for (const leftPath of leftWitness.writes) {
|
|
504
|
+
for (const rightPath of rightWitness.writes) {
|
|
505
|
+
if (!pathPrefixOverlap(leftPath, rightPath)) continue;
|
|
506
|
+
if (leftOwnPaths.has(leftPath) && rightOwnPaths.has(rightPath)
|
|
507
|
+
&& leftPath === rightPath) continue;
|
|
508
|
+
unresolved.push({
|
|
509
|
+
todo_id: todoIds[left],
|
|
510
|
+
kind: 'undeclared_write_overlap',
|
|
511
|
+
ref: `${leftPath} ${rightPath}`,
|
|
512
|
+
});
|
|
513
|
+
unresolved.push({
|
|
514
|
+
todo_id: todoIds[right],
|
|
515
|
+
kind: 'undeclared_write_overlap',
|
|
516
|
+
ref: `${leftPath} ${rightPath}`,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
for (const todoId of todoIds) {
|
|
523
|
+
for (const unknown of virtualRequest.manual_witness[todoId].unknowns) {
|
|
524
|
+
unresolved.push({ todo_id: todoId, kind: unknown.kind, ref: unknown.ref });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const readWriteGroups = new Map();
|
|
529
|
+
for (let left = 0; left < todoIds.length; left += 1) {
|
|
530
|
+
for (let right = 0; right < todoIds.length; right += 1) {
|
|
531
|
+
if (left === right) continue;
|
|
532
|
+
const writer = virtualRequest.manual_witness[todoIds[left]];
|
|
533
|
+
const reader = virtualRequest.manual_witness[todoIds[right]];
|
|
534
|
+
for (const writePath of writer.writes) {
|
|
535
|
+
for (const readPath of reader.reads) {
|
|
536
|
+
if (!pathPrefixOverlap(writePath, readPath)) continue;
|
|
537
|
+
if (!readWriteGroups.has(writePath)) readWriteGroups.set(writePath, new Set());
|
|
538
|
+
readWriteGroups.get(writePath).add(todoIds[left]);
|
|
539
|
+
readWriteGroups.get(writePath).add(todoIds[right]);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const bareResourceGroups = new Map();
|
|
546
|
+
for (const todoId of todoIds) {
|
|
547
|
+
const witness = virtualRequest.manual_witness[todoId];
|
|
548
|
+
const stateIds = new Set(witness.state_effects.map(({ resource_id: id }) => id));
|
|
549
|
+
for (const resourceId of witness.resources) {
|
|
550
|
+
if (stateIds.has(resourceId)) continue;
|
|
551
|
+
if (!bareResourceGroups.has(resourceId)) bareResourceGroups.set(resourceId, new Set());
|
|
552
|
+
bareResourceGroups.get(resourceId).add(todoId);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const resources = [];
|
|
557
|
+
for (const [key, group] of [...ownGroups.entries()].sort((left, right) => compareText(left[0], right[0]))) {
|
|
558
|
+
const coveringQueryId = coveringByTarget.get(key);
|
|
559
|
+
const observedTodoIds = new Set();
|
|
560
|
+
let outcome;
|
|
561
|
+
let representative;
|
|
562
|
+
if (coveringQueryId !== undefined) {
|
|
563
|
+
outcome = outcomeById.get(coveringQueryId);
|
|
564
|
+
for (const todoId of group.todoIds) {
|
|
565
|
+
const binding = bindingsByTodo.get(todoId).find((entry) => (
|
|
566
|
+
entry.query_id === coveringQueryId && bindingCoversOwn(entry, group.own)
|
|
567
|
+
));
|
|
568
|
+
if (representative === undefined) representative = binding;
|
|
569
|
+
if (binding !== undefined
|
|
570
|
+
&& statusOutcome.status === 'ready'
|
|
571
|
+
&& resolveBindingStatus(binding, outcome) === 'ready') {
|
|
572
|
+
observedTodoIds.add(todoId);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const allObserved = group.todoIds.every((todoId) => observedTodoIds.has(todoId));
|
|
577
|
+
const allCovered = group.todoIds.every((todoId) => (
|
|
578
|
+
observedTodoIds.has(todoId) || group.hypotheticalTodoIds.has(todoId)
|
|
579
|
+
));
|
|
580
|
+
const hasHypothetical = group.todoIds.some(
|
|
581
|
+
(todoId) => group.hypotheticalTodoIds.has(todoId),
|
|
582
|
+
);
|
|
583
|
+
if (coveringQueryId === undefined && !allCovered) continue;
|
|
584
|
+
const status = allObserved ? 'observed'
|
|
585
|
+
: allCovered && hasHypothetical ? 'hypothetical' : 'unknown';
|
|
586
|
+
const provenance = status === 'hypothetical'
|
|
587
|
+
? [{
|
|
588
|
+
source: HYPOTHESIS_PROVENANCE,
|
|
589
|
+
evidence_ref: `virtual-witness:${virtualRequest.request_id}`,
|
|
590
|
+
evidence_digest: digestArtifact(hypotheses),
|
|
591
|
+
status: 'hypothetical',
|
|
592
|
+
}]
|
|
593
|
+
: [
|
|
594
|
+
candidateProvenance(virtualRequest),
|
|
595
|
+
{
|
|
596
|
+
source: 'sensor',
|
|
597
|
+
evidence_ref: coveringQueryId,
|
|
598
|
+
evidence_digest: outcome.portable_digest,
|
|
599
|
+
status: statusOutcome.status !== 'ready'
|
|
600
|
+
? statusOutcome.status
|
|
601
|
+
: resolveBindingStatus(representative, outcome),
|
|
602
|
+
},
|
|
603
|
+
];
|
|
604
|
+
resources.push({
|
|
605
|
+
resource_id: `own-${group.own.kind}-${sha16(group.own.target)}`,
|
|
606
|
+
kind: group.own.kind,
|
|
607
|
+
target: group.own.target,
|
|
608
|
+
todo_ids: [...group.todoIds].sort(compareText),
|
|
609
|
+
provenance,
|
|
610
|
+
status,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const stateGroups = new Map();
|
|
615
|
+
for (const todoId of todoIds) {
|
|
616
|
+
for (const entry of virtualRequest.manual_witness[todoId].state_effects) {
|
|
617
|
+
const kind = STATE_KIND_MAP[entry.kind];
|
|
618
|
+
if (!stateGroups.has(entry.resource_id)) {
|
|
619
|
+
stateGroups.set(entry.resource_id, { kind, todoIds: new Set() });
|
|
620
|
+
}
|
|
621
|
+
const group = stateGroups.get(entry.resource_id);
|
|
622
|
+
if (group.kind !== kind) fail(`resource ${entry.resource_id}のstate/effect kindが矛盾している`);
|
|
623
|
+
group.todoIds.add(todoId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const [resourceId, todos] of bareResourceGroups) {
|
|
627
|
+
if (stateGroups.has(resourceId)) {
|
|
628
|
+
for (const todoId of todos) stateGroups.get(resourceId).todoIds.add(todoId);
|
|
629
|
+
} else {
|
|
630
|
+
stateGroups.set(resourceId, { kind: 'state', todoIds: todos });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
for (const [resourceId, group] of [...stateGroups.entries()]
|
|
634
|
+
.sort((left, right) => compareText(left[0], right[0]))) {
|
|
635
|
+
resources.push({
|
|
636
|
+
resource_id: resourceId,
|
|
637
|
+
kind: group.kind,
|
|
638
|
+
target: resourceId,
|
|
639
|
+
todo_ids: [...group.todoIds].sort(compareText),
|
|
640
|
+
provenance: [manualProvenance(virtualRequest)],
|
|
641
|
+
status: 'observed',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
for (const [writePath, todos] of [...readWriteGroups.entries()]
|
|
645
|
+
.sort((left, right) => compareText(left[0], right[0]))) {
|
|
646
|
+
resources.push({
|
|
647
|
+
resource_id: `rw-${sha16(writePath)}`,
|
|
648
|
+
kind: 'state',
|
|
649
|
+
target: writePath,
|
|
650
|
+
todo_ids: [...todos].sort(compareText),
|
|
651
|
+
provenance: [manualProvenance(virtualRequest)],
|
|
652
|
+
status: 'observed',
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
let dynamicIndex = 0;
|
|
657
|
+
for (const unknown of unresolved) {
|
|
658
|
+
resources.push({
|
|
659
|
+
resource_id: `dyn-${String(dynamicIndex += 1).padStart(3, '0')}-${sha16(`${unknown.kind}:${unknown.ref}`)}`,
|
|
660
|
+
kind: 'dynamic',
|
|
661
|
+
target: `${unknown.kind}:${unknown.ref}`.slice(0, 4_096).trim(),
|
|
662
|
+
todo_ids: [unknown.todo_id],
|
|
663
|
+
provenance: [manualProvenance(virtualRequest)],
|
|
664
|
+
status: 'unknown',
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
resources.sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
668
|
+
if (new Set(resources.map(({ resource_id: id }) => id)).size !== resources.length) {
|
|
669
|
+
fail('導出resource_idが重複している');
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const conflicts = [];
|
|
673
|
+
const unknowns = [];
|
|
674
|
+
for (const resource of resources) {
|
|
675
|
+
if (resource.status === 'unknown') {
|
|
676
|
+
for (const todoId of resource.todo_ids) {
|
|
677
|
+
unknowns.push({
|
|
678
|
+
todo_id: todoId,
|
|
679
|
+
kind: resource.kind === 'dynamic'
|
|
680
|
+
? 'dynamic' : `sensor_${resource.provenance.find(({ source }) => source === 'sensor').status}`,
|
|
681
|
+
reason: `resource ${resource.resource_id} is ${
|
|
682
|
+
resource.kind === 'dynamic'
|
|
683
|
+
? 'dynamic'
|
|
684
|
+
: resource.provenance.find(({ source }) => source === 'sensor').status
|
|
685
|
+
}`,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
for (let left = 0; left < resource.todo_ids.length; left += 1) {
|
|
691
|
+
for (let right = left + 1; right < resource.todo_ids.length; right += 1) {
|
|
692
|
+
conflicts.push({
|
|
693
|
+
todo_ids: [resource.todo_ids[left], resource.todo_ids[right]],
|
|
694
|
+
resource_id: resource.resource_id,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
conflicts.sort((left, right) => compareText(
|
|
700
|
+
`${left.todo_ids[0]}\0${left.todo_ids[1]}\0${left.resource_id}`,
|
|
701
|
+
`${right.todo_ids[0]}\0${right.todo_ids[1]}\0${right.resource_id}`,
|
|
702
|
+
));
|
|
703
|
+
unknowns.sort((left, right) => compareText(
|
|
704
|
+
`${left.todo_id}\0${left.kind}\0${left.reason}`,
|
|
705
|
+
`${right.todo_id}\0${right.kind}\0${right.reason}`,
|
|
706
|
+
));
|
|
707
|
+
|
|
708
|
+
return derivationResult({
|
|
709
|
+
outcome: unknowns.length > 0 ? 'unknown' : 'derived',
|
|
710
|
+
virtualWitness: virtualRequest.manual_witness,
|
|
711
|
+
hypotheses,
|
|
712
|
+
resources,
|
|
713
|
+
conflicts,
|
|
714
|
+
unknowns,
|
|
715
|
+
unresolvedWitnesses: unresolved,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function createVirtualCompileReceipt(options = {}) {
|
|
720
|
+
const derivation = deriveVirtualBoundary(options);
|
|
721
|
+
return {
|
|
722
|
+
derivation,
|
|
723
|
+
verification: {
|
|
724
|
+
virtual_compile_input_digest: derivation.input_digest,
|
|
725
|
+
virtual_compile_result_digest: derivation.result_digest,
|
|
726
|
+
residual_conflicts: derivation.conflicts,
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Re-derive from source inputs. This distinguishes a caller-written SHA-shaped receipt from a
|
|
733
|
+
* reproducible virtual compile.
|
|
734
|
+
*/
|
|
735
|
+
export function verifyVirtualCompileReceipt({ verification, ...options } = {}) {
|
|
736
|
+
const expected = createVirtualCompileReceipt(options);
|
|
737
|
+
const mismatches = [];
|
|
738
|
+
if (verification?.virtual_compile_input_digest
|
|
739
|
+
!== expected.verification.virtual_compile_input_digest) {
|
|
740
|
+
mismatches.push('virtual_compile_input_digest');
|
|
741
|
+
}
|
|
742
|
+
if (verification?.virtual_compile_result_digest
|
|
743
|
+
!== expected.verification.virtual_compile_result_digest) {
|
|
744
|
+
mismatches.push('virtual_compile_result_digest');
|
|
745
|
+
}
|
|
746
|
+
if (digestArtifact(verification?.residual_conflicts)
|
|
747
|
+
!== digestArtifact(expected.verification.residual_conflicts)) {
|
|
748
|
+
mismatches.push('residual_conflicts');
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
valid: mismatches.length === 0,
|
|
752
|
+
mismatches,
|
|
753
|
+
expected: expected.verification,
|
|
754
|
+
derivation: expected.derivation,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** 宣言されたconcern symbolを、`query` operationの解決receiptから探す。 */
|
|
759
|
+
function resolvedSymbolPath(queries, name) {
|
|
760
|
+
const receipt = queries.find((query) => (
|
|
761
|
+
query.operation === 'query'
|
|
762
|
+
&& query.target === name
|
|
763
|
+
&& query.outcome === 'resolved'
|
|
764
|
+
&& query.resolved_name === name
|
|
765
|
+
&& typeof query.resolved_path === 'string'
|
|
766
|
+
&& query.resolved_path.length > 0
|
|
767
|
+
));
|
|
768
|
+
return receipt === undefined ? null : receipt.resolved_path;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function pathContains(resourcePath, symbolPath) {
|
|
772
|
+
return resourcePath.endsWith('/')
|
|
773
|
+
? symbolPath.startsWith(resourcePath)
|
|
774
|
+
: symbolPath === resourcePath;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* 宣言symbolのpathを、宣言した資源の内側で解く(ADR 0134)。
|
|
779
|
+
*
|
|
780
|
+
* 一意に解決した名前はそのまま返す。同名が複数fileに居た場合だけ、receiptが持つ候補を
|
|
781
|
+
* 資源で絞る——絞って1つに決まる時だけ束縛根拠になる。0個なら宣言した資源の中には無く、
|
|
782
|
+
* 2つ以上なら資源の中でも決まらない。どちらも「解けなかった」であって、片方を勝たせない。
|
|
783
|
+
*
|
|
784
|
+
* 絞るのは宣言された資源であって、宣言そのものではない。sensorが返した候補集合の外から
|
|
785
|
+
* pathを持ち込むことはないので、宣言が誤っていても存在しないsymbolへは束縛されない。
|
|
786
|
+
*/
|
|
787
|
+
function resolveConcernSymbolPath(queries, name, resourcePath) {
|
|
788
|
+
const receipt = queries.find((query) => (
|
|
789
|
+
query.operation === 'query' && query.target === name && query.resolved_name === name
|
|
790
|
+
&& (query.outcome === 'resolved' || query.outcome === 'ambiguous')
|
|
791
|
+
));
|
|
792
|
+
if (receipt === undefined) return null;
|
|
793
|
+
if (receipt.outcome === 'resolved') {
|
|
794
|
+
return typeof receipt.resolved_path === 'string' && receipt.resolved_path.length > 0
|
|
795
|
+
? receipt.resolved_path : null;
|
|
796
|
+
}
|
|
797
|
+
const inside = (receipt.candidate_paths ?? [])
|
|
798
|
+
.filter((candidate) => pathContains(resourcePath, candidate));
|
|
799
|
+
return inside.length === 1 ? inside[0] : null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Resolve declared concern anchors against fresh sensor evidence.
|
|
804
|
+
*
|
|
805
|
+
* A declaration only becomes a binding anchor when the sensor resolves the exact name inside the
|
|
806
|
+
* declared resource to exactly one path. Fuzzy resolution to a neighbouring symbol, an absent
|
|
807
|
+
* name, or a symbol living outside the contested resource yields a typed unknown instead — a
|
|
808
|
+
* wrong declaration must never widen what the binder believes it knows.
|
|
809
|
+
*
|
|
810
|
+
* A name living in several files is resolved against the declared resource rather than being
|
|
811
|
+
* dropped (ADR 0134). The resource is one the ToDo already claims under `owns`, so this narrows
|
|
812
|
+
* the sensor's own candidate set; it never introduces a path the sensor did not report.
|
|
813
|
+
*
|
|
814
|
+
* Two ToDos claiming the same symbol is a contradiction in the declarations themselves, not a cut
|
|
815
|
+
* to be discovered: the anchor is dropped from both and reported, so neither side can be bound by
|
|
816
|
+
* a claim the other also makes.
|
|
817
|
+
*/
|
|
818
|
+
export function resolveConcernAnchors({ manualWitness, taskIds, evidence } = {}) {
|
|
819
|
+
if (!plainRecord(manualWitness) || !Array.isArray(taskIds) || !plainRecord(evidence)) {
|
|
820
|
+
fail('concern anchor resolution input shapeが不正');
|
|
821
|
+
}
|
|
822
|
+
// receiptが1件も無い証拠は「解決しなかった」であって、宣言を素通しさせる理由にはしない。
|
|
823
|
+
const queries = Array.isArray(evidence.queries) ? evidence.queries : [];
|
|
824
|
+
const anchorsByTask = new Map();
|
|
825
|
+
const unknowns = [];
|
|
826
|
+
for (const taskId of taskIds) {
|
|
827
|
+
const anchors = [];
|
|
828
|
+
for (const entry of manualWitness[taskId]?.concern_anchors ?? []) {
|
|
829
|
+
const resourcePath = entry.within.kind === 'path'
|
|
830
|
+
? entry.within.target
|
|
831
|
+
: resolvedSymbolPath(queries, entry.within.target);
|
|
832
|
+
if (resourcePath === null) {
|
|
833
|
+
unknowns.push({
|
|
834
|
+
kind: 'concern_anchor_resource_unresolved',
|
|
835
|
+
ref: `${taskId}:${entry.within.kind}:${entry.within.target}`,
|
|
836
|
+
});
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
for (const symbol of entry.symbols) {
|
|
840
|
+
const symbolPath = resolveConcernSymbolPath(queries, symbol, resourcePath);
|
|
841
|
+
if (symbolPath === null) {
|
|
842
|
+
unknowns.push({ kind: 'concern_anchor_unresolved', ref: `${taskId}:${symbol}` });
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (!pathContains(resourcePath, symbolPath)) {
|
|
846
|
+
unknowns.push({
|
|
847
|
+
kind: 'concern_anchor_outside_resource',
|
|
848
|
+
ref: `${taskId}:${symbol}:${symbolPath}`,
|
|
849
|
+
});
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
anchors.push(`concern:${symbolPath}\0${symbol}`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
anchorsByTask.set(taskId, sortedUnique(anchors));
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// 同じsymbolを2 task以上が担当と主張したら、どちらの束縛根拠にもしない。
|
|
859
|
+
const claimantsByAnchor = new Map();
|
|
860
|
+
for (const [taskId, anchors] of anchorsByTask) {
|
|
861
|
+
for (const anchor of anchors) {
|
|
862
|
+
if (!claimantsByAnchor.has(anchor)) claimantsByAnchor.set(anchor, []);
|
|
863
|
+
claimantsByAnchor.get(anchor).push(taskId);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const overlapping = new Set();
|
|
867
|
+
for (const [anchor, claimants] of claimantsByAnchor) {
|
|
868
|
+
if (claimants.length < 2) continue;
|
|
869
|
+
overlapping.add(anchor);
|
|
870
|
+
const [path, symbol] = anchor.slice('concern:'.length).split('\0');
|
|
871
|
+
unknowns.push({
|
|
872
|
+
kind: 'concern_anchor_overlap',
|
|
873
|
+
ref: `${[...claimants].sort(compareText).join(',')}:${path}:${symbol}`,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
if (overlapping.size > 0) {
|
|
877
|
+
for (const [taskId, anchors] of anchorsByTask) {
|
|
878
|
+
anchorsByTask.set(taskId, anchors.filter((anchor) => !overlapping.has(anchor)));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
anchorsByTask,
|
|
884
|
+
unknowns: unknowns.sort((left, right) => compareText(
|
|
885
|
+
`${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
|
|
886
|
+
)),
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** witness set全体から、sensorへ問い合わせるconcern symbol名を集める。 */
|
|
891
|
+
export function declaredConcernSymbols(manualWitness) {
|
|
892
|
+
if (!plainRecord(manualWitness)) fail('manual witness shapeが不正');
|
|
893
|
+
const names = [];
|
|
894
|
+
for (const witness of Object.values(manualWitness)) {
|
|
895
|
+
for (const entry of witness?.concern_anchors ?? []) {
|
|
896
|
+
names.push(...entry.symbols);
|
|
897
|
+
if (entry.within.kind === 'symbol') names.push(entry.within.target);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return sortedUnique(names);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function uniqueIntentAnchors(manualWitness, taskIds) {
|
|
904
|
+
const anchorsByTask = new Map();
|
|
905
|
+
const counts = new Map();
|
|
906
|
+
for (const taskId of taskIds) {
|
|
907
|
+
const witness = manualWitness[taskId];
|
|
908
|
+
const anchors = [
|
|
909
|
+
...witness.owns.map((own) => `owns:${resourceKey(own)}`),
|
|
910
|
+
...witness.writes.map((path) => `writes:${path}`),
|
|
911
|
+
...witness.affected_tests.map((path) => `affected_tests:${path}`),
|
|
912
|
+
];
|
|
913
|
+
anchorsByTask.set(taskId, sortedUnique(anchors));
|
|
914
|
+
for (const anchor of new Set(anchors)) counts.set(anchor, (counts.get(anchor) ?? 0) + 1);
|
|
915
|
+
}
|
|
916
|
+
return new Map([...anchorsByTask].map(([taskId, anchors]) => [
|
|
917
|
+
taskId,
|
|
918
|
+
anchors.filter((anchor) => counts.get(anchor) === 1),
|
|
919
|
+
]));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function graphNode(entry) {
|
|
923
|
+
const node = plainRecord(entry?.node) ? entry.node : entry;
|
|
924
|
+
if (!plainRecord(node)
|
|
925
|
+
|| typeof node.name !== 'string'
|
|
926
|
+
|| node.name.length === 0
|
|
927
|
+
|| typeof node.filePath !== 'string'
|
|
928
|
+
|| node.filePath.length === 0
|
|
929
|
+
|| node.filePath.startsWith('/')) return null;
|
|
930
|
+
if (node.kind === 'file') {
|
|
931
|
+
return { kind: 'path', target: node.filePath, path: node.filePath };
|
|
932
|
+
}
|
|
933
|
+
return { kind: 'symbol', target: node.name, path: node.filePath };
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function graphNodeKey(node) {
|
|
937
|
+
return `${node.kind}\0${node.target}\0${node.path}`;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function graphEdgeKey(edge) {
|
|
941
|
+
return `${edge.from}\0${edge.to}\0${edge.kind}`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function graphPayload(outcome, operation) {
|
|
945
|
+
if (!plainRecord(outcome) || outcome.outcome !== 'ready') return null;
|
|
946
|
+
if (operation === 'query') return outcome.data;
|
|
947
|
+
if (!plainRecord(outcome.data)) return null;
|
|
948
|
+
if (operation === 'callers') return outcome.data.callers;
|
|
949
|
+
if (operation === 'callees') return outcome.data.callees;
|
|
950
|
+
if (operation === 'impact') return outcome.data.affected;
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function normalizeRawGraph({ conflict, evidence, rawCollected }) {
|
|
955
|
+
if (!plainRecord(rawCollected) || !Array.isArray(rawCollected.outcomes)) {
|
|
956
|
+
return { graph: null, unknown: 'raw_graph_unavailable' };
|
|
957
|
+
}
|
|
958
|
+
const receipts = evidence.queries.filter((query) => (
|
|
959
|
+
query.target === conflict.target && STRUCTURE_OPERATIONS.has(query.operation)
|
|
960
|
+
));
|
|
961
|
+
if (receipts.length !== STRUCTURE_OPERATIONS.size
|
|
962
|
+
|| receipts.some((query) => query.outcome !== 'resolved'
|
|
963
|
+
|| query.resolved_name !== conflict.target
|
|
964
|
+
|| query.resolved_path === null)) {
|
|
965
|
+
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
966
|
+
}
|
|
967
|
+
const outcomeById = new Map(rawCollected.outcomes.map((outcome) => [outcome?.id, outcome]));
|
|
968
|
+
const receiptByOperation = new Map(receipts.map((receipt) => [receipt.operation, receipt]));
|
|
969
|
+
const payloadByOperation = new Map();
|
|
970
|
+
for (const operation of STRUCTURE_OPERATIONS) {
|
|
971
|
+
const receipt = receiptByOperation.get(operation);
|
|
972
|
+
const payload = graphPayload(outcomeById.get(receipt.query_id), operation);
|
|
973
|
+
if (!Array.isArray(payload)) {
|
|
974
|
+
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
975
|
+
}
|
|
976
|
+
payloadByOperation.set(operation, payload);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const queryReceipt = receiptByOperation.get('query');
|
|
980
|
+
const rootMatches = payloadByOperation.get('query')
|
|
981
|
+
.map(graphNode)
|
|
982
|
+
.filter((node) => node !== null
|
|
983
|
+
&& node.kind === 'symbol'
|
|
984
|
+
&& node.target === conflict.target
|
|
985
|
+
&& node.path === queryReceipt.resolved_path);
|
|
986
|
+
if (rootMatches.length !== 1) {
|
|
987
|
+
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
988
|
+
}
|
|
989
|
+
const root = rootMatches[0];
|
|
990
|
+
const nodes = new Map([[graphNodeKey(root), root]]);
|
|
991
|
+
const edges = new Map();
|
|
992
|
+
let invalidObservedNode = false;
|
|
993
|
+
const addObserved = (entry) => {
|
|
994
|
+
const node = graphNode(entry);
|
|
995
|
+
if (node === null) invalidObservedNode = true;
|
|
996
|
+
if (node !== null) nodes.set(graphNodeKey(node), node);
|
|
997
|
+
return node;
|
|
998
|
+
};
|
|
999
|
+
for (const entry of payloadByOperation.get('callers')) {
|
|
1000
|
+
const node = addObserved(entry);
|
|
1001
|
+
if (node === null) continue;
|
|
1002
|
+
const edge = { from: graphNodeKey(node), to: graphNodeKey(root), kind: 'caller' };
|
|
1003
|
+
edges.set(graphEdgeKey(edge), edge);
|
|
1004
|
+
}
|
|
1005
|
+
for (const entry of payloadByOperation.get('callees')) {
|
|
1006
|
+
const node = addObserved(entry);
|
|
1007
|
+
if (node === null) continue;
|
|
1008
|
+
const edge = { from: graphNodeKey(root), to: graphNodeKey(node), kind: 'callee' };
|
|
1009
|
+
edges.set(graphEdgeKey(edge), edge);
|
|
1010
|
+
}
|
|
1011
|
+
for (const entry of payloadByOperation.get('impact')) addObserved(entry);
|
|
1012
|
+
let closureComplete = plainRecord(rawCollected.graph_closure)
|
|
1013
|
+
&& rawCollected.graph_closure.complete === true
|
|
1014
|
+
&& Array.isArray(rawCollected.graph_closure.expansions);
|
|
1015
|
+
if (plainRecord(rawCollected.graph_closure)
|
|
1016
|
+
&& Array.isArray(rawCollected.graph_closure.expansions)) {
|
|
1017
|
+
for (const expansion of rawCollected.graph_closure.expansions) {
|
|
1018
|
+
const parent = graphNode(expansion?.parent);
|
|
1019
|
+
const rawCallees = expansion?.callees_outcome;
|
|
1020
|
+
if (expansion?.exact !== true
|
|
1021
|
+
|| parent === null
|
|
1022
|
+
|| !plainRecord(rawCallees)
|
|
1023
|
+
|| rawCallees.outcome !== 'ready'
|
|
1024
|
+
|| !plainRecord(rawCallees.data)
|
|
1025
|
+
|| !Array.isArray(rawCallees.data.callees)) {
|
|
1026
|
+
closureComplete = false;
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
nodes.set(graphNodeKey(parent), parent);
|
|
1030
|
+
for (const entry of rawCallees.data.callees) {
|
|
1031
|
+
const child = addObserved(entry);
|
|
1032
|
+
if (child === null) {
|
|
1033
|
+
closureComplete = false;
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const edge = {
|
|
1037
|
+
from: graphNodeKey(parent),
|
|
1038
|
+
to: graphNodeKey(child),
|
|
1039
|
+
kind: 'callee',
|
|
1040
|
+
};
|
|
1041
|
+
edges.set(graphEdgeKey(edge), edge);
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (invalidObservedNode) {
|
|
1046
|
+
return { graph: null, unknown: 'raw_graph_incomplete' };
|
|
1047
|
+
}
|
|
1048
|
+
return {
|
|
1049
|
+
graph: {
|
|
1050
|
+
root,
|
|
1051
|
+
closure_complete: closureComplete,
|
|
1052
|
+
nodes: [...nodes.values()].sort((left, right) => compareText(
|
|
1053
|
+
graphNodeKey(left), graphNodeKey(right),
|
|
1054
|
+
)),
|
|
1055
|
+
edges: [...edges.values()].sort((left, right) => compareText(
|
|
1056
|
+
graphEdgeKey(left), graphEdgeKey(right),
|
|
1057
|
+
)),
|
|
1058
|
+
},
|
|
1059
|
+
unknown: null,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function stronglyConnectedPartitions(graph) {
|
|
1064
|
+
const symbolKeys = new Set(graph.nodes
|
|
1065
|
+
.filter(({ kind }) => kind === 'symbol').map(graphNodeKey));
|
|
1066
|
+
const adjacency = new Map([...symbolKeys].map((key) => [key, []]));
|
|
1067
|
+
for (const edge of graph.edges) {
|
|
1068
|
+
if (symbolKeys.has(edge.from) && symbolKeys.has(edge.to)) {
|
|
1069
|
+
adjacency.get(edge.from).push(edge.to);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
for (const targets of adjacency.values()) targets.sort(compareText);
|
|
1073
|
+
let nextIndex = 0;
|
|
1074
|
+
const indexes = new Map();
|
|
1075
|
+
const lowLinks = new Map();
|
|
1076
|
+
const stack = [];
|
|
1077
|
+
const onStack = new Set();
|
|
1078
|
+
const components = [];
|
|
1079
|
+
const visit = (key) => {
|
|
1080
|
+
indexes.set(key, nextIndex);
|
|
1081
|
+
lowLinks.set(key, nextIndex);
|
|
1082
|
+
nextIndex += 1;
|
|
1083
|
+
stack.push(key);
|
|
1084
|
+
onStack.add(key);
|
|
1085
|
+
for (const target of adjacency.get(key)) {
|
|
1086
|
+
if (!indexes.has(target)) {
|
|
1087
|
+
visit(target);
|
|
1088
|
+
lowLinks.set(key, Math.min(lowLinks.get(key), lowLinks.get(target)));
|
|
1089
|
+
} else if (onStack.has(target)) {
|
|
1090
|
+
lowLinks.set(key, Math.min(lowLinks.get(key), indexes.get(target)));
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (lowLinks.get(key) !== indexes.get(key)) return;
|
|
1094
|
+
const component = [];
|
|
1095
|
+
while (stack.length > 0) {
|
|
1096
|
+
const member = stack.pop();
|
|
1097
|
+
onStack.delete(member);
|
|
1098
|
+
component.push(member);
|
|
1099
|
+
if (member === key) break;
|
|
1100
|
+
}
|
|
1101
|
+
components.push(component.sort(compareText));
|
|
1102
|
+
};
|
|
1103
|
+
for (const key of [...symbolKeys].sort(compareText)) {
|
|
1104
|
+
if (!indexes.has(key)) visit(key);
|
|
1105
|
+
}
|
|
1106
|
+
return components.sort((left, right) => compareText(left.join('\0'), right.join('\0')));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function calleeClosurePartitions(graph) {
|
|
1110
|
+
const adjacency = new Map(graph.nodes.map((node) => [graphNodeKey(node), []]));
|
|
1111
|
+
for (const edge of graph.edges.filter(({ kind }) => kind === 'callee')) {
|
|
1112
|
+
adjacency.get(edge.from)?.push(edge.to);
|
|
1113
|
+
}
|
|
1114
|
+
for (const targets of adjacency.values()) targets.sort(compareText);
|
|
1115
|
+
const direct = adjacency.get(graphNodeKey(graph.root)) ?? [];
|
|
1116
|
+
return direct.map((start) => {
|
|
1117
|
+
const seen = new Set();
|
|
1118
|
+
const queue = [start];
|
|
1119
|
+
while (queue.length > 0) {
|
|
1120
|
+
const key = queue.shift();
|
|
1121
|
+
if (seen.has(key)) continue;
|
|
1122
|
+
seen.add(key);
|
|
1123
|
+
queue.push(...(adjacency.get(key) ?? []));
|
|
1124
|
+
}
|
|
1125
|
+
return [...seen].sort(compareText);
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function moduleFrontierPartitions(graph) {
|
|
1130
|
+
const byPath = new Map();
|
|
1131
|
+
for (const node of graph.nodes) {
|
|
1132
|
+
if (!byPath.has(node.path)) byPath.set(node.path, []);
|
|
1133
|
+
byPath.get(node.path).push(graphNodeKey(node));
|
|
1134
|
+
}
|
|
1135
|
+
return [...byPath.entries()].sort((left, right) => compareText(left[0], right[0]))
|
|
1136
|
+
.map(([, keys]) => keys.sort(compareText));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function testFrontierPartitions(graph) {
|
|
1140
|
+
return graph.nodes
|
|
1141
|
+
.filter(({ kind, path }) => kind === 'path'
|
|
1142
|
+
&& (path.startsWith('test/') || /\.test\.[cm]?[jt]sx?$/u.test(path)))
|
|
1143
|
+
.map((node) => [graphNodeKey(node)]);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function canonicalPartitions(partitions) {
|
|
1147
|
+
const unique = new Set();
|
|
1148
|
+
for (const partition of partitions) {
|
|
1149
|
+
const key = sortedUnique(partition).join('\u0001');
|
|
1150
|
+
if (key.length > 0) unique.add(key);
|
|
1151
|
+
}
|
|
1152
|
+
return [...unique].sort(compareText).map((key) => key.split('\u0001'));
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function anchorMatchesNode(anchor, node) {
|
|
1156
|
+
if (anchor.startsWith('concern:')) {
|
|
1157
|
+
const separator = anchor.indexOf('\0');
|
|
1158
|
+
return node.kind === 'symbol'
|
|
1159
|
+
&& node.path === anchor.slice('concern:'.length, separator)
|
|
1160
|
+
&& node.target === anchor.slice(separator + 1);
|
|
1161
|
+
}
|
|
1162
|
+
if (anchor.startsWith('owns:symbol\0')) {
|
|
1163
|
+
return node.kind === 'symbol' && node.target === anchor.slice('owns:symbol\0'.length);
|
|
1164
|
+
}
|
|
1165
|
+
if (anchor.startsWith('owns:path\0')) {
|
|
1166
|
+
return node.path === anchor.slice('owns:path\0'.length);
|
|
1167
|
+
}
|
|
1168
|
+
if (anchor.startsWith('writes:')) return node.path === anchor.slice('writes:'.length);
|
|
1169
|
+
if (anchor.startsWith('affected_tests:')) {
|
|
1170
|
+
return node.path === anchor.slice('affected_tests:'.length);
|
|
1171
|
+
}
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Bind tasks to partitions.
|
|
1177
|
+
*
|
|
1178
|
+
* Declared concern anchors take precedence over the coarse owns/writes/test anchors within one
|
|
1179
|
+
* skeleton: a ToDo that named the symbols it touches inside the contested resource has given
|
|
1180
|
+
* strictly more specific evidence than "it owns some file". The coarse anchors stay as the
|
|
1181
|
+
* fallback for skeletons the declaration says nothing about, so declaring a concern for one
|
|
1182
|
+
* conflict never blinds the binder to another.
|
|
1183
|
+
*/
|
|
1184
|
+
function bindSkeleton({ skeleton, graph, intentAnchors, concernAnchors, taskIds }) {
|
|
1185
|
+
const nodeByKey = new Map(graph.nodes.map((node) => [graphNodeKey(node), node]));
|
|
1186
|
+
const taskBindings = [];
|
|
1187
|
+
const unknowns = [];
|
|
1188
|
+
const matchesFor = (anchors) => {
|
|
1189
|
+
const matched = [];
|
|
1190
|
+
skeleton.partitions.forEach((partition, index) => {
|
|
1191
|
+
const hits = anchors.filter((anchor) => (
|
|
1192
|
+
partition.some((key) => anchorMatchesNode(anchor, nodeByKey.get(key)))
|
|
1193
|
+
));
|
|
1194
|
+
if (hits.length > 0) matched.push({ index, anchors: sortedUnique(hits) });
|
|
1195
|
+
});
|
|
1196
|
+
return matched;
|
|
1197
|
+
};
|
|
1198
|
+
for (const taskId of taskIds) {
|
|
1199
|
+
const declared = matchesFor(concernAnchors.get(taskId) ?? []);
|
|
1200
|
+
const matches = declared.length > 0 ? declared : matchesFor(intentAnchors.get(taskId));
|
|
1201
|
+
if (matches.length === 0) {
|
|
1202
|
+
unknowns.push({
|
|
1203
|
+
kind: 'semantic_owner_binding_missing',
|
|
1204
|
+
ref: `${skeleton.skeleton_id}:${taskId}`,
|
|
1205
|
+
});
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
if (matches.length > 1) {
|
|
1209
|
+
unknowns.push({
|
|
1210
|
+
kind: 'semantic_owner_binding_ambiguous',
|
|
1211
|
+
ref: `${skeleton.skeleton_id}:${taskId}`,
|
|
1212
|
+
});
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
taskBindings.push({
|
|
1216
|
+
task_id: taskId,
|
|
1217
|
+
partition_index: matches[0].index,
|
|
1218
|
+
anchors: matches[0].anchors,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if (taskBindings.length === taskIds.length
|
|
1222
|
+
&& new Set(taskBindings.map(({ partition_index: index }) => index)).size
|
|
1223
|
+
!== taskBindings.length) {
|
|
1224
|
+
unknowns.push({
|
|
1225
|
+
kind: 'semantic_owner_binding_ambiguous',
|
|
1226
|
+
ref: `${skeleton.skeleton_id}:shared_partition`,
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
return {
|
|
1230
|
+
...skeleton,
|
|
1231
|
+
task_bindings: taskBindings,
|
|
1232
|
+
binding_unknowns: unknowns,
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Build a cut skeleton for a contested repo path out of the declared concern anchors alone.
|
|
1238
|
+
*
|
|
1239
|
+
* A path conflict has no call graph to partition — the sensor was only asked which tests the file
|
|
1240
|
+
* affects. What the declarations do give is a partition of the file's symbols by owner, which is
|
|
1241
|
+
* exactly the shape a cut needs. Every task in the component must have named at least one symbol
|
|
1242
|
+
* inside the path; otherwise there is nothing to say about who owns which half and the caller
|
|
1243
|
+
* keeps reporting the resource as unavailable rather than guessing.
|
|
1244
|
+
*/
|
|
1245
|
+
function declaredPartitionSkeleton({ conflict, concernAnchors, taskIds }) {
|
|
1246
|
+
const rootPath = conflict.target;
|
|
1247
|
+
const root = { kind: 'path', target: rootPath, path: rootPath };
|
|
1248
|
+
const nodes = [root];
|
|
1249
|
+
const partitions = [];
|
|
1250
|
+
for (const taskId of taskIds) {
|
|
1251
|
+
const owned = (concernAnchors.get(taskId) ?? [])
|
|
1252
|
+
.map((anchor) => {
|
|
1253
|
+
const separator = anchor.indexOf('\0');
|
|
1254
|
+
return {
|
|
1255
|
+
path: anchor.slice('concern:'.length, separator),
|
|
1256
|
+
name: anchor.slice(separator + 1),
|
|
1257
|
+
};
|
|
1258
|
+
})
|
|
1259
|
+
.filter(({ path }) => pathContains(rootPath, path));
|
|
1260
|
+
if (owned.length === 0) return null;
|
|
1261
|
+
for (const { path, name } of owned) nodes.push({ kind: 'symbol', target: name, path });
|
|
1262
|
+
partitions.push(owned.map(({ path, name }) => graphNodeKey({
|
|
1263
|
+
kind: 'symbol', target: name, path,
|
|
1264
|
+
})));
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
root,
|
|
1268
|
+
nodes,
|
|
1269
|
+
partitions: canonicalPartitions(partitions),
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Enumerate structural cut skeletons from the in-memory sensor outcomes. Graph edges only shape
|
|
1275
|
+
* SCC/closure/frontier partitions; task ownership is bound exclusively by unique witness anchors
|
|
1276
|
+
* and by declared concern anchors, never by the edges themselves.
|
|
1277
|
+
*/
|
|
1278
|
+
export function enumerateCutSkeletons({
|
|
1279
|
+
component,
|
|
1280
|
+
request,
|
|
1281
|
+
evidence,
|
|
1282
|
+
rawCollected,
|
|
1283
|
+
concernAnchors = new Map(),
|
|
1284
|
+
} = {}) {
|
|
1285
|
+
if (!plainRecord(component)
|
|
1286
|
+
|| !Array.isArray(component.task_ids)
|
|
1287
|
+
|| !Array.isArray(component.conflicts)
|
|
1288
|
+
|| !plainRecord(request)
|
|
1289
|
+
|| !plainRecord(evidence)) {
|
|
1290
|
+
fail('cut skeleton enumeration input shapeが不正');
|
|
1291
|
+
}
|
|
1292
|
+
const taskIds = [...component.task_ids].sort(compareText);
|
|
1293
|
+
const intentAnchors = uniqueIntentAnchors(request.manual_witness, taskIds);
|
|
1294
|
+
const missingIntent = taskIds.filter((taskId) => (
|
|
1295
|
+
intentAnchors.get(taskId).length === 0 && (concernAnchors.get(taskId) ?? []).length === 0
|
|
1296
|
+
));
|
|
1297
|
+
if (missingIntent.length > 0) {
|
|
1298
|
+
return {
|
|
1299
|
+
skeletons: [],
|
|
1300
|
+
unknowns: missingIntent.map((taskId) => ({
|
|
1301
|
+
kind: 'semantic_owner_binding_missing',
|
|
1302
|
+
ref: taskId,
|
|
1303
|
+
})),
|
|
1304
|
+
exploration_complete: false,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const skeletonByLayout = new Map();
|
|
1309
|
+
const unknowns = [];
|
|
1310
|
+
for (const conflict of component.conflicts) {
|
|
1311
|
+
if (conflict.kind === 'path') {
|
|
1312
|
+
const declared = declaredPartitionSkeleton({ conflict, concernAnchors, taskIds });
|
|
1313
|
+
if (declared === null || declared.partitions.length < 2) {
|
|
1314
|
+
unknowns.push({ kind: 'raw_graph_unavailable', ref: conflict.resource_id });
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
const layoutKey = digestArtifact({
|
|
1318
|
+
conflict_resource_id: conflict.resource_id,
|
|
1319
|
+
partitions: declared.partitions,
|
|
1320
|
+
});
|
|
1321
|
+
skeletonByLayout.set(layoutKey, {
|
|
1322
|
+
skeleton_id: `cut-${sha16(layoutKey)}`,
|
|
1323
|
+
conflict_resource_id: conflict.resource_id,
|
|
1324
|
+
cut_kinds: ['declared_partition'],
|
|
1325
|
+
root_surface: structuredClone(declared.root),
|
|
1326
|
+
partitions: declared.partitions,
|
|
1327
|
+
raw_graph: { nodes: structuredClone(declared.nodes), edges: [] },
|
|
1328
|
+
});
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
if (conflict.kind !== 'symbol') {
|
|
1332
|
+
unknowns.push({ kind: 'raw_graph_unavailable', ref: conflict.resource_id });
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
const normalized = normalizeRawGraph({ conflict, evidence, rawCollected });
|
|
1336
|
+
if (normalized.graph === null) {
|
|
1337
|
+
unknowns.push({ kind: normalized.unknown, ref: conflict.resource_id });
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
const graph = normalized.graph;
|
|
1341
|
+
if (!graph.closure_complete) {
|
|
1342
|
+
unknowns.push({ kind: 'raw_graph_incomplete', ref: conflict.resource_id });
|
|
1343
|
+
}
|
|
1344
|
+
const variants = [
|
|
1345
|
+
...(graph.closure_complete ? [
|
|
1346
|
+
['scc', stronglyConnectedPartitions(graph)],
|
|
1347
|
+
['callee_closure', calleeClosurePartitions(graph)],
|
|
1348
|
+
] : []),
|
|
1349
|
+
['module_frontier', moduleFrontierPartitions(graph)],
|
|
1350
|
+
['task_test_frontier', testFrontierPartitions(graph)],
|
|
1351
|
+
];
|
|
1352
|
+
for (const [cutKind, rawPartitions] of variants) {
|
|
1353
|
+
const partitions = canonicalPartitions(rawPartitions);
|
|
1354
|
+
if (partitions.length < 2) continue;
|
|
1355
|
+
const layoutKey = digestArtifact({
|
|
1356
|
+
conflict_resource_id: conflict.resource_id,
|
|
1357
|
+
partitions,
|
|
1358
|
+
});
|
|
1359
|
+
const existing = skeletonByLayout.get(layoutKey);
|
|
1360
|
+
if (existing !== undefined) {
|
|
1361
|
+
existing.cut_kinds = sortedUnique([...existing.cut_kinds, cutKind]);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
skeletonByLayout.set(layoutKey, {
|
|
1365
|
+
skeleton_id: `cut-${sha16(layoutKey)}`,
|
|
1366
|
+
conflict_resource_id: conflict.resource_id,
|
|
1367
|
+
cut_kinds: [cutKind],
|
|
1368
|
+
root_surface: structuredClone(graph.root),
|
|
1369
|
+
partitions,
|
|
1370
|
+
raw_graph: {
|
|
1371
|
+
nodes: structuredClone(graph.nodes),
|
|
1372
|
+
edges: structuredClone(graph.edges),
|
|
1373
|
+
},
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const skeletons = [...skeletonByLayout.values()]
|
|
1378
|
+
.sort((left, right) => compareText(left.skeleton_id, right.skeleton_id))
|
|
1379
|
+
.map((skeleton) => bindSkeleton({
|
|
1380
|
+
skeleton,
|
|
1381
|
+
graph: skeleton.raw_graph,
|
|
1382
|
+
intentAnchors,
|
|
1383
|
+
concernAnchors,
|
|
1384
|
+
taskIds,
|
|
1385
|
+
}));
|
|
1386
|
+
for (const skeleton of skeletons) unknowns.push(...skeleton.binding_unknowns);
|
|
1387
|
+
if (skeletons.length === 0 && unknowns.length === 0) {
|
|
1388
|
+
unknowns.push({ kind: 'raw_graph_incomplete', ref: component.component_id });
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
skeletons,
|
|
1392
|
+
unknowns: unknowns.sort((left, right) => compareText(
|
|
1393
|
+
`${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
|
|
1394
|
+
)),
|
|
1395
|
+
exploration_complete: unknowns.length === 0,
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function rawGraphSurfaceSet(rawGraph) {
|
|
1400
|
+
if (!exactRecord(rawGraph, ['nodes', 'edges'])
|
|
1401
|
+
|| !Array.isArray(rawGraph.nodes)
|
|
1402
|
+
|| !Array.isArray(rawGraph.edges)) return null;
|
|
1403
|
+
const surfaces = new Set();
|
|
1404
|
+
for (const node of rawGraph.nodes) {
|
|
1405
|
+
if (!exactRecord(node, ['kind', 'target', 'path'])
|
|
1406
|
+
|| !['symbol', 'path'].includes(node.kind)
|
|
1407
|
+
|| typeof node.target !== 'string'
|
|
1408
|
+
|| typeof node.path !== 'string') return null;
|
|
1409
|
+
surfaces.add(`${node.kind}\0${node.target}\0${node.path}`);
|
|
1410
|
+
}
|
|
1411
|
+
return surfaces;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function currentSurfaces(component, evidence) {
|
|
1415
|
+
const ownerByConflict = new Map(component.conflicts.map((conflict) => [
|
|
1416
|
+
conflict.resource_id,
|
|
1417
|
+
sortedUnique(conflict.task_pairs.flat()),
|
|
1418
|
+
]));
|
|
1419
|
+
const surfaces = [];
|
|
1420
|
+
for (const conflict of component.conflicts) {
|
|
1421
|
+
if (!['symbol', 'path'].includes(conflict.kind)) continue;
|
|
1422
|
+
let path = conflict.target;
|
|
1423
|
+
if (conflict.kind === 'symbol') {
|
|
1424
|
+
const receipt = evidence.queries.find((query) => (
|
|
1425
|
+
query.target === conflict.target
|
|
1426
|
+
&& query.outcome === 'resolved'
|
|
1427
|
+
&& query.resolved_name === conflict.target
|
|
1428
|
+
&& query.resolved_path !== null
|
|
1429
|
+
));
|
|
1430
|
+
if (receipt === undefined) return null;
|
|
1431
|
+
path = receipt.resolved_path;
|
|
1432
|
+
}
|
|
1433
|
+
surfaces.push({
|
|
1434
|
+
kind: conflict.kind,
|
|
1435
|
+
target: conflict.target,
|
|
1436
|
+
path,
|
|
1437
|
+
role: conflict.kind === 'symbol' ? 'shared_symbol' : 'shared_path',
|
|
1438
|
+
owner_task_ids: ownerByConflict.get(conflict.resource_id),
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
return surfaces.sort((left, right) => compareText(surfaceKey(left), surfaceKey(right)));
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function candidateDominates(left, right) {
|
|
1445
|
+
const noWorse = left.minimumWaves <= right.minimumWaves
|
|
1446
|
+
&& isSubset(left.changedSurfaces, right.changedSurfaces)
|
|
1447
|
+
&& isSubset(left.blastRadius, right.blastRadius);
|
|
1448
|
+
const strict = left.minimumWaves < right.minimumWaves
|
|
1449
|
+
|| left.changedSurfaces.size < right.changedSurfaces.size
|
|
1450
|
+
|| left.blastRadius.size < right.blastRadius.size;
|
|
1451
|
+
return noWorse && strict;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function decisionUnknown(component, unknowns, reasons = []) {
|
|
1455
|
+
return {
|
|
1456
|
+
component_id: component.component_id,
|
|
1457
|
+
task_ids: [...component.task_ids],
|
|
1458
|
+
conflicts: structuredClone(component.conflicts),
|
|
1459
|
+
verdict: 'unknown_requires_evidence',
|
|
1460
|
+
seam_candidate: null,
|
|
1461
|
+
reasons: reasons.sort((left, right) => compareText(
|
|
1462
|
+
`${left.code}\0${left.detail}`, `${right.code}\0${right.detail}`,
|
|
1463
|
+
)),
|
|
1464
|
+
unknowns: unknowns.sort((left, right) => compareText(
|
|
1465
|
+
`${left.kind}\0${left.ref}`, `${right.kind}\0${right.ref}`,
|
|
1466
|
+
)),
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Low-level evaluator for already-materialized skeletons. Task assignment must have been bound
|
|
1472
|
+
* before this point; graph edges are never accepted as ownership evidence.
|
|
1473
|
+
*/
|
|
1474
|
+
export function evaluateSeamProposalCandidates({
|
|
1475
|
+
component,
|
|
1476
|
+
request,
|
|
1477
|
+
sensorEvidence,
|
|
1478
|
+
evidence,
|
|
1479
|
+
candidateSpecs,
|
|
1480
|
+
explorationComplete = false,
|
|
1481
|
+
concernAnchors = new Map(),
|
|
1482
|
+
} = {}) {
|
|
1483
|
+
if (!plainRecord(component)
|
|
1484
|
+
|| !Array.isArray(component.task_ids)
|
|
1485
|
+
|| !Array.isArray(component.conflicts)
|
|
1486
|
+
|| !Array.isArray(candidateSpecs)
|
|
1487
|
+
|| !plainRecord(evidence)) {
|
|
1488
|
+
fail('decision input shapeが不正');
|
|
1489
|
+
}
|
|
1490
|
+
const taskIds = [...component.task_ids].sort(compareText);
|
|
1491
|
+
if (taskIds.some((taskId, index) => taskId !== component.task_ids[index])) {
|
|
1492
|
+
fail('component.task_idsがstrict sortされていない');
|
|
1493
|
+
}
|
|
1494
|
+
const taskSet = new Set(taskIds);
|
|
1495
|
+
const intentAnchors = uniqueIntentAnchors(request.manual_witness, taskIds);
|
|
1496
|
+
const missingIntent = taskIds.filter((taskId) => (
|
|
1497
|
+
intentAnchors.get(taskId).length === 0 && (concernAnchors.get(taskId) ?? []).length === 0
|
|
1498
|
+
));
|
|
1499
|
+
if (missingIntent.length > 0) {
|
|
1500
|
+
return decisionUnknown(component, missingIntent.map((taskId) => ({
|
|
1501
|
+
kind: 'semantic_owner_binding_missing',
|
|
1502
|
+
ref: taskId,
|
|
1503
|
+
})));
|
|
1504
|
+
}
|
|
1505
|
+
if (candidateSpecs.length === 0) {
|
|
1506
|
+
return decisionUnknown(component, [{
|
|
1507
|
+
kind: 'candidate_exploration_incomplete',
|
|
1508
|
+
ref: component.component_id,
|
|
1509
|
+
}]);
|
|
1510
|
+
}
|
|
1511
|
+
const current = currentSurfaces(component, evidence);
|
|
1512
|
+
if (current === null) {
|
|
1513
|
+
return decisionUnknown(component, [{
|
|
1514
|
+
kind: 'exact_surface_evidence_missing',
|
|
1515
|
+
ref: component.component_id,
|
|
1516
|
+
}]);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const evaluated = [];
|
|
1520
|
+
const rejectedUnknowns = [];
|
|
1521
|
+
const candidateIds = candidateSpecs.map(({ candidate_id: candidateId }) => candidateId);
|
|
1522
|
+
if (new Set(candidateIds).size !== candidateIds.length) {
|
|
1523
|
+
fail('candidate_idが重複している');
|
|
1524
|
+
}
|
|
1525
|
+
for (const spec of candidateSpecs) {
|
|
1526
|
+
if (!exactRecord(spec, [
|
|
1527
|
+
'candidate_id', 'ownership_diff', 'proposed_surfaces',
|
|
1528
|
+
'surface_hypotheses', 'raw_graph',
|
|
1529
|
+
])) {
|
|
1530
|
+
fail('candidate specがclosed shapeではない');
|
|
1531
|
+
}
|
|
1532
|
+
const graphSurfaces = rawGraphSurfaceSet(spec.raw_graph);
|
|
1533
|
+
if (graphSurfaces === null) {
|
|
1534
|
+
rejectedUnknowns.push({ kind: 'raw_graph_unavailable', ref: spec.candidate_id });
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
if (current.some((surface) => !graphSurfaces.has(
|
|
1538
|
+
`${surface.kind}\0${surface.target}\0${surface.path}`,
|
|
1539
|
+
))) {
|
|
1540
|
+
rejectedUnknowns.push({ kind: 'raw_graph_incomplete', ref: spec.candidate_id });
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (!Array.isArray(spec.proposed_surfaces)
|
|
1544
|
+
|| spec.proposed_surfaces.some((surface) => !plainRecord(surface))) {
|
|
1545
|
+
fail('candidate proposed_surfacesが不正');
|
|
1546
|
+
}
|
|
1547
|
+
const hypothesisKeys = new Set(spec.surface_hypotheses.map((entry) => (
|
|
1548
|
+
`${entry.kind}\0${entry.target}\0${entry.path}`
|
|
1549
|
+
)));
|
|
1550
|
+
const graphIncomplete = spec.proposed_surfaces.some((surface) => {
|
|
1551
|
+
const key = `${surface.kind}\0${surface.target}\0${surface.path}`;
|
|
1552
|
+
return !graphSurfaces.has(key) && !hypothesisKeys.has(key);
|
|
1553
|
+
});
|
|
1554
|
+
if (graphIncomplete) {
|
|
1555
|
+
rejectedUnknowns.push({ kind: 'new_surface_assumption_missing', ref: spec.candidate_id });
|
|
1556
|
+
continue;
|
|
1557
|
+
}
|
|
1558
|
+
const diffIds = spec.ownership_diff.map(({ todo_id: todoId }) => todoId);
|
|
1559
|
+
if (diffIds.length !== taskIds.length
|
|
1560
|
+
|| new Set(diffIds).size !== taskIds.length
|
|
1561
|
+
|| diffIds.some((taskId) => !taskSet.has(taskId))) {
|
|
1562
|
+
fail('candidate ownership_diffがcomponent全taskのfull diffではない');
|
|
1563
|
+
}
|
|
1564
|
+
const virtualWitness = buildVirtualWitness({
|
|
1565
|
+
request,
|
|
1566
|
+
ownershipDiff: spec.ownership_diff,
|
|
1567
|
+
});
|
|
1568
|
+
const proposedOwnershipMismatch = spec.proposed_surfaces.some((surface) => {
|
|
1569
|
+
if (!Array.isArray(surface.owner_task_ids)) return true;
|
|
1570
|
+
const owners = taskIds.filter((taskId) => virtualWitness[taskId].owns
|
|
1571
|
+
.some((own) => resourceKey(own) === `${surface.kind}\0${surface.target}`));
|
|
1572
|
+
return owners.length !== surface.owner_task_ids.length
|
|
1573
|
+
|| owners.some((taskId, index) => taskId !== surface.owner_task_ids[index]);
|
|
1574
|
+
});
|
|
1575
|
+
if (proposedOwnershipMismatch) {
|
|
1576
|
+
rejectedUnknowns.push({
|
|
1577
|
+
kind: 'virtual_witness_surface_mismatch',
|
|
1578
|
+
ref: spec.candidate_id,
|
|
1579
|
+
});
|
|
1580
|
+
continue;
|
|
1581
|
+
}
|
|
1582
|
+
const receipt = createVirtualCompileReceipt({
|
|
1583
|
+
request,
|
|
1584
|
+
sensorEvidence,
|
|
1585
|
+
virtualWitness,
|
|
1586
|
+
surfaceHypotheses: spec.surface_hypotheses,
|
|
1587
|
+
});
|
|
1588
|
+
const derivation = receipt.derivation;
|
|
1589
|
+
if (derivation.outcome !== 'derived'
|
|
1590
|
+
|| derivation.unknowns.length > 0
|
|
1591
|
+
|| derivation.conflicts.length > 0) {
|
|
1592
|
+
const kind = derivation.drift.length > 0
|
|
1593
|
+
? derivation.drift[0].kind
|
|
1594
|
+
: derivation.unknowns.length > 0 ? 'virtual_boundary_unknown' : 'residual_conflict';
|
|
1595
|
+
rejectedUnknowns.push({ kind, ref: spec.candidate_id });
|
|
1596
|
+
evaluated.push({ spec, derivation, feasible: false });
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
const proposed = structuredClone(spec.proposed_surfaces)
|
|
1601
|
+
.sort((left, right) => compareText(surfaceKey(left), surfaceKey(right)));
|
|
1602
|
+
const affectedTests = sortedUnique(taskIds.flatMap(
|
|
1603
|
+
(taskId) => virtualWitness[taskId].affected_tests,
|
|
1604
|
+
));
|
|
1605
|
+
const limits = spec.surface_hypotheses.length > 0
|
|
1606
|
+
? ['hypothetical_new_surfaces', 'structural_only']
|
|
1607
|
+
: ['structural_only'];
|
|
1608
|
+
const candidate = {
|
|
1609
|
+
proposal_id: deriveSeamProposalId({
|
|
1610
|
+
conflicts: component.conflicts,
|
|
1611
|
+
proposed_surfaces: proposed,
|
|
1612
|
+
}),
|
|
1613
|
+
current_surfaces: current,
|
|
1614
|
+
proposed_surfaces: proposed,
|
|
1615
|
+
affected_tests: affectedTests,
|
|
1616
|
+
verification: {
|
|
1617
|
+
virtual_compile_input_digest: receipt.verification.virtual_compile_input_digest,
|
|
1618
|
+
virtual_compile_result_digest: receipt.verification.virtual_compile_result_digest,
|
|
1619
|
+
residual_conflicts: [],
|
|
1620
|
+
},
|
|
1621
|
+
evidence: structuredClone(evidence),
|
|
1622
|
+
limits,
|
|
1623
|
+
proposal_digest: '',
|
|
1624
|
+
};
|
|
1625
|
+
candidate.proposal_digest = todoSelfDigest(candidate, 'proposal_digest');
|
|
1626
|
+
const changedSurfaces = new Set(proposed.map(surfaceKey));
|
|
1627
|
+
const blastRadius = new Set([
|
|
1628
|
+
...proposed.map(({ path }) => `path:${path}`),
|
|
1629
|
+
...affectedTests.map((path) => `test:${path}`),
|
|
1630
|
+
]);
|
|
1631
|
+
evaluated.push({
|
|
1632
|
+
spec,
|
|
1633
|
+
derivation,
|
|
1634
|
+
feasible: true,
|
|
1635
|
+
candidate,
|
|
1636
|
+
minimumWaves: Math.ceil(taskIds.length / request.capacity.executors),
|
|
1637
|
+
changedSurfaces,
|
|
1638
|
+
blastRadius,
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const feasible = evaluated.filter(({ feasible }) => feasible);
|
|
1643
|
+
const nonDominated = feasible.filter((candidate, index) => (
|
|
1644
|
+
!feasible.some((other, otherIndex) => (
|
|
1645
|
+
index !== otherIndex && candidateDominates(other, candidate)
|
|
1646
|
+
))
|
|
1647
|
+
));
|
|
1648
|
+
if (nonDominated.length === 1) {
|
|
1649
|
+
return {
|
|
1650
|
+
component_id: component.component_id,
|
|
1651
|
+
task_ids: taskIds,
|
|
1652
|
+
conflicts: structuredClone(component.conflicts),
|
|
1653
|
+
verdict: 'seam_candidate',
|
|
1654
|
+
seam_candidate: nonDominated[0].candidate,
|
|
1655
|
+
reasons: [{
|
|
1656
|
+
code: 'unique_structural_dominant_candidate',
|
|
1657
|
+
detail: 'One feasible candidate structurally dominates all alternatives.',
|
|
1658
|
+
}],
|
|
1659
|
+
unknowns: [],
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
if (nonDominated.length > 1) {
|
|
1663
|
+
return decisionUnknown(component, [{
|
|
1664
|
+
kind: 'multiple_incomparable_candidates',
|
|
1665
|
+
ref: nonDominated.map(({ spec }) => spec.candidate_id).sort(compareText).join(','),
|
|
1666
|
+
}]);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const serialKinds = new Set(['state', 'effect']);
|
|
1670
|
+
const unseverable = component.conflicts.filter(({ kind }) => serialKinds.has(kind));
|
|
1671
|
+
const unseverableRemains = unseverable.length > 0
|
|
1672
|
+
&& evaluated.length === candidateSpecs.length
|
|
1673
|
+
&& evaluated.every(({ derivation }) => unseverable.some((conflict) => (
|
|
1674
|
+
derivation.conflicts.some(({ resource_id: resourceId }) => (
|
|
1675
|
+
resourceId === conflict.resource_id || resourceId === conflict.target
|
|
1676
|
+
))
|
|
1677
|
+
)));
|
|
1678
|
+
if (explorationComplete && unseverableRemains) {
|
|
1679
|
+
return {
|
|
1680
|
+
component_id: component.component_id,
|
|
1681
|
+
task_ids: taskIds,
|
|
1682
|
+
conflicts: structuredClone(component.conflicts),
|
|
1683
|
+
verdict: 'intentional_serial',
|
|
1684
|
+
seam_candidate: null,
|
|
1685
|
+
reasons: [{
|
|
1686
|
+
code: 'unseverable_state_effect_conflict',
|
|
1687
|
+
detail: 'Complete exploration retained a current-contract state/effect conflict.',
|
|
1688
|
+
}],
|
|
1689
|
+
unknowns: [],
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
const unknowns = rejectedUnknowns.length > 0
|
|
1693
|
+
? rejectedUnknowns
|
|
1694
|
+
: [{ kind: 'candidate_exploration_incomplete', ref: component.component_id }];
|
|
1695
|
+
if (!explorationComplete) {
|
|
1696
|
+
unknowns.push({ kind: 'candidate_exploration_incomplete', ref: component.component_id });
|
|
1697
|
+
}
|
|
1698
|
+
return decisionUnknown(component, unknowns);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function extractionPath(rootPath, skeletonId, taskId) {
|
|
1702
|
+
const slash = rootPath.lastIndexOf('/');
|
|
1703
|
+
const directory = slash === -1 ? '' : rootPath.slice(0, slash + 1);
|
|
1704
|
+
const filename = slash === -1 ? rootPath : rootPath.slice(slash + 1);
|
|
1705
|
+
const dot = filename.lastIndexOf('.');
|
|
1706
|
+
const stem = dot <= 0 ? filename : filename.slice(0, dot);
|
|
1707
|
+
const extension = dot <= 0 ? '.mjs' : filename.slice(dot);
|
|
1708
|
+
return `${directory}${stem}.seam-${sha16(`${skeletonId}\0${taskId}`)}${extension}`;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function skeletonCandidateSpec({ skeleton, component, request, evidence }) {
|
|
1712
|
+
const taskIds = [...component.task_ids].sort(compareText);
|
|
1713
|
+
if (skeleton.binding_unknowns.length > 0
|
|
1714
|
+
|| skeleton.task_bindings.length !== taskIds.length) return null;
|
|
1715
|
+
const current = currentSurfaces(component, evidence);
|
|
1716
|
+
if (current === null) return null;
|
|
1717
|
+
const conflictKeys = new Set(component.conflicts.map(({ kind, target }) => (
|
|
1718
|
+
`${kind}\0${target}`
|
|
1719
|
+
)));
|
|
1720
|
+
const currentPaths = sortedUnique(current.map(({ path }) => path));
|
|
1721
|
+
const ownershipDiff = [];
|
|
1722
|
+
const proposedSurfaces = [];
|
|
1723
|
+
const surfaceHypotheses = [];
|
|
1724
|
+
for (const taskId of taskIds) {
|
|
1725
|
+
const original = request.manual_witness[taskId];
|
|
1726
|
+
const path = extractionPath(skeleton.root_surface.path, skeleton.skeleton_id, taskId);
|
|
1727
|
+
const replacePath = (value) => (
|
|
1728
|
+
currentPaths.some((currentPath) => pathPrefixOverlap(value, currentPath)) ? path : value
|
|
1729
|
+
);
|
|
1730
|
+
const owns = original.owns.filter((own) => (
|
|
1731
|
+
!conflictKeys.has(resourceKey(own))
|
|
1732
|
+
&& !(own.kind === 'path' && currentPaths.includes(own.target))
|
|
1733
|
+
));
|
|
1734
|
+
owns.push({ kind: 'path', target: path });
|
|
1735
|
+
const sensorQueries = original.sensor_provenance.queries.filter(({ expect }) => {
|
|
1736
|
+
if (expect.kind === 'symbol') {
|
|
1737
|
+
return !component.conflicts.some((conflict) => (
|
|
1738
|
+
conflict.kind === 'symbol' && conflict.target === expect.name
|
|
1739
|
+
));
|
|
1740
|
+
}
|
|
1741
|
+
return !currentPaths.includes(expect.path);
|
|
1742
|
+
});
|
|
1743
|
+
ownershipDiff.push({
|
|
1744
|
+
todo_id: taskId,
|
|
1745
|
+
owns: owns.sort((left, right) => compareText(resourceKey(left), resourceKey(right))),
|
|
1746
|
+
reads: sortedUnique(original.reads.map(replacePath)),
|
|
1747
|
+
writes: sortedUnique([...original.writes.map(replacePath), path]),
|
|
1748
|
+
resources: structuredClone(original.resources),
|
|
1749
|
+
state_effects: structuredClone(original.state_effects),
|
|
1750
|
+
sensor_provenance: { queries: structuredClone(sensorQueries) },
|
|
1751
|
+
affected_tests: structuredClone(original.affected_tests),
|
|
1752
|
+
unknowns: structuredClone(original.unknowns),
|
|
1753
|
+
});
|
|
1754
|
+
proposedSurfaces.push({
|
|
1755
|
+
kind: 'path',
|
|
1756
|
+
target: path,
|
|
1757
|
+
path,
|
|
1758
|
+
role: 'task_owned',
|
|
1759
|
+
owner_task_ids: [taskId],
|
|
1760
|
+
});
|
|
1761
|
+
surfaceHypotheses.push({
|
|
1762
|
+
kind: 'path',
|
|
1763
|
+
target: path,
|
|
1764
|
+
path,
|
|
1765
|
+
owner_task_id: taskId,
|
|
1766
|
+
affected_tests: structuredClone(original.affected_tests),
|
|
1767
|
+
provenance: HYPOTHESIS_PROVENANCE,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
return {
|
|
1771
|
+
candidate_id: skeleton.skeleton_id,
|
|
1772
|
+
ownership_diff: ownershipDiff,
|
|
1773
|
+
proposed_surfaces: proposedSurfaces,
|
|
1774
|
+
surface_hypotheses: surfaceHypotheses,
|
|
1775
|
+
raw_graph: structuredClone(skeleton.raw_graph),
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/**
|
|
1780
|
+
* Enumerate, bind, materialize, and validate structural cut skeletons. Callers provide collected
|
|
1781
|
+
* sensor outcomes, never handwritten candidate specs.
|
|
1782
|
+
*/
|
|
1783
|
+
export function compileSeamProposalDecision({
|
|
1784
|
+
component,
|
|
1785
|
+
request,
|
|
1786
|
+
sensorEvidence,
|
|
1787
|
+
evidence,
|
|
1788
|
+
rawCollected,
|
|
1789
|
+
concernAnchors = new Map(),
|
|
1790
|
+
concernUnknowns = [],
|
|
1791
|
+
} = {}) {
|
|
1792
|
+
const enumeration = enumerateCutSkeletons({
|
|
1793
|
+
component,
|
|
1794
|
+
request,
|
|
1795
|
+
evidence,
|
|
1796
|
+
rawCollected,
|
|
1797
|
+
concernAnchors,
|
|
1798
|
+
});
|
|
1799
|
+
// 宣言が解決しなかった事実は、束縛が成功したかに関わらず記録から消さない。
|
|
1800
|
+
const unknowns = [...concernUnknowns, ...enumeration.unknowns];
|
|
1801
|
+
if (unknowns.length > 0) {
|
|
1802
|
+
return decisionUnknown(component, unknowns);
|
|
1803
|
+
}
|
|
1804
|
+
const candidateSpecs = enumeration.skeletons.map((skeleton) => (
|
|
1805
|
+
skeletonCandidateSpec({ skeleton, component, request, evidence })
|
|
1806
|
+
));
|
|
1807
|
+
if (candidateSpecs.some((spec) => spec === null)) {
|
|
1808
|
+
return decisionUnknown(component, [{
|
|
1809
|
+
kind: 'new_surface_assumption_missing',
|
|
1810
|
+
ref: component.component_id,
|
|
1811
|
+
}]);
|
|
1812
|
+
}
|
|
1813
|
+
return evaluateSeamProposalCandidates({
|
|
1814
|
+
component,
|
|
1815
|
+
request,
|
|
1816
|
+
sensorEvidence,
|
|
1817
|
+
evidence,
|
|
1818
|
+
candidateSpecs,
|
|
1819
|
+
explorationComplete: enumeration.exploration_complete,
|
|
1820
|
+
concernAnchors,
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
export class SeamProposalCompileError extends Error {
|
|
1825
|
+
constructor(code, reason, detail = {}) {
|
|
1826
|
+
super(reason);
|
|
1827
|
+
this.name = 'SeamProposalCompileError';
|
|
1828
|
+
this.code = code;
|
|
1829
|
+
this.detail = { reason, ...detail };
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function compileFail(code, reason, detail) {
|
|
1834
|
+
throw new SeamProposalCompileError(code, reason, detail);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function conflictComponents(independenceArtifact) {
|
|
1838
|
+
const resourceById = new Map(independenceArtifact.conflict_resources.map((resource) => (
|
|
1839
|
+
[resource.resource_id, resource]
|
|
1840
|
+
)));
|
|
1841
|
+
const parent = new Map();
|
|
1842
|
+
const find = (taskId) => {
|
|
1843
|
+
const current = parent.get(taskId) ?? taskId;
|
|
1844
|
+
if (!parent.has(taskId)) parent.set(taskId, taskId);
|
|
1845
|
+
if (current === taskId) return taskId;
|
|
1846
|
+
const root = find(current);
|
|
1847
|
+
parent.set(taskId, root);
|
|
1848
|
+
return root;
|
|
1849
|
+
};
|
|
1850
|
+
const union = (left, right) => {
|
|
1851
|
+
const leftRoot = find(left);
|
|
1852
|
+
const rightRoot = find(right);
|
|
1853
|
+
if (leftRoot === rightRoot) return;
|
|
1854
|
+
if (compareText(leftRoot, rightRoot) < 0) parent.set(rightRoot, leftRoot);
|
|
1855
|
+
else parent.set(leftRoot, rightRoot);
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
for (const { task_ids: [left, right] } of independenceArtifact.conflicts) {
|
|
1859
|
+
union(left, right);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
const conflictsByRoot = new Map();
|
|
1863
|
+
for (const conflict of independenceArtifact.conflicts) {
|
|
1864
|
+
const root = find(conflict.task_ids[0]);
|
|
1865
|
+
const entries = conflictsByRoot.get(root) ?? [];
|
|
1866
|
+
entries.push(conflict);
|
|
1867
|
+
conflictsByRoot.set(root, entries);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const components = [];
|
|
1871
|
+
const classifiedPairKeys = new Set();
|
|
1872
|
+
const classifiedResourceIds = new Set();
|
|
1873
|
+
for (const entries of conflictsByRoot.values()) {
|
|
1874
|
+
const taskIds = sortedUnique(entries.flatMap(({ task_ids: taskPair }) => taskPair));
|
|
1875
|
+
const pairsByResource = new Map();
|
|
1876
|
+
for (const entry of entries) {
|
|
1877
|
+
const pairs = pairsByResource.get(entry.resource_id) ?? [];
|
|
1878
|
+
pairs.push([...entry.task_ids]);
|
|
1879
|
+
pairsByResource.set(entry.resource_id, pairs);
|
|
1880
|
+
classifiedPairKeys.add(`${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`);
|
|
1881
|
+
}
|
|
1882
|
+
const conflicts = [...pairsByResource].map(([resourceId, pairs]) => {
|
|
1883
|
+
const resource = resourceById.get(resourceId);
|
|
1884
|
+
if (resource === undefined) {
|
|
1885
|
+
compileFail('SEAM_PROPOSAL_COMPONENT_INVALID', 'conflict_resource_missing', {
|
|
1886
|
+
resource_id: resourceId,
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
if (classifiedResourceIds.has(resourceId)) {
|
|
1890
|
+
compileFail('SEAM_PROPOSAL_COMPONENT_INVALID', 'conflict_resource_classified_twice', {
|
|
1891
|
+
resource_id: resourceId,
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
classifiedResourceIds.add(resourceId);
|
|
1895
|
+
return {
|
|
1896
|
+
...structuredClone(resource),
|
|
1897
|
+
task_pairs: pairs.sort((left, right) => compareText(
|
|
1898
|
+
`${left[0]}\0${left[1]}`, `${right[0]}\0${right[1]}`,
|
|
1899
|
+
)),
|
|
1900
|
+
};
|
|
1901
|
+
}).sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
1902
|
+
const identity = { task_ids: taskIds, conflicts };
|
|
1903
|
+
components.push({
|
|
1904
|
+
component_id: `component-${digestTodoArtifact(identity).slice(0, 24)}`,
|
|
1905
|
+
...identity,
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
const expectedPairKeys = new Set(independenceArtifact.conflicts.map((entry) => (
|
|
1910
|
+
`${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`
|
|
1911
|
+
)));
|
|
1912
|
+
if (classifiedPairKeys.size !== expectedPairKeys.size
|
|
1913
|
+
|| [...expectedPairKeys].some((key) => !classifiedPairKeys.has(key))
|
|
1914
|
+
|| classifiedResourceIds.size !== independenceArtifact.conflict_resources.length) {
|
|
1915
|
+
compileFail('SEAM_PROPOSAL_COMPONENT_INVALID', 'conflict_component_partition_incomplete', {
|
|
1916
|
+
expected_pair_count: expectedPairKeys.size,
|
|
1917
|
+
classified_pair_count: classifiedPairKeys.size,
|
|
1918
|
+
expected_resource_count: independenceArtifact.conflict_resources.length,
|
|
1919
|
+
classified_resource_count: classifiedResourceIds.size,
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
return components.sort((left, right) => compareText(left.component_id, right.component_id));
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* Build the immutable lattice.seam_proposal.v2 artifact from one complete independence record.
|
|
1927
|
+
* Sensor collection stays outside this producer; callers pass the original witness evidence and
|
|
1928
|
+
* the seam-specific normalized/raw evidence collected for the same clean HEAD.
|
|
1929
|
+
*/
|
|
1930
|
+
export function compileSeamProposalArtifact({
|
|
1931
|
+
independenceArtifact,
|
|
1932
|
+
witnessSet,
|
|
1933
|
+
plan,
|
|
1934
|
+
compiledAt,
|
|
1935
|
+
sensorEvidence,
|
|
1936
|
+
evidence,
|
|
1937
|
+
rawCollected,
|
|
1938
|
+
} = {}) {
|
|
1939
|
+
if (!validateTodoIndependence(independenceArtifact)) {
|
|
1940
|
+
compileFail('SEAM_PROPOSAL_INDEPENDENCE_INVALID', 'independence_artifact_invalid');
|
|
1941
|
+
}
|
|
1942
|
+
if (independenceArtifact.outcome !== 'compiled') {
|
|
1943
|
+
compileFail('SEAM_PROPOSAL_INDEPENDENCE_UNAVAILABLE', 'independence_outcome_not_compiled', {
|
|
1944
|
+
outcome: independenceArtifact.outcome,
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
if (!validateTodoWitnessSet(witnessSet)) {
|
|
1948
|
+
compileFail('SEAM_PROPOSAL_WITNESS_INVALID', 'witness_set_invalid');
|
|
1949
|
+
}
|
|
1950
|
+
if (!validateTodoPlan(plan)) {
|
|
1951
|
+
compileFail('SEAM_PROPOSAL_PLAN_INVALID', 'plan_invalid');
|
|
1952
|
+
}
|
|
1953
|
+
if (independenceArtifact.project_id !== plan.project_id
|
|
1954
|
+
|| independenceArtifact.plan_key !== plan.plan_key
|
|
1955
|
+
|| independenceArtifact.plan_version !== plan.plan_version
|
|
1956
|
+
|| independenceArtifact.topology_digest !== plan.topology_digest) {
|
|
1957
|
+
compileFail('SEAM_PROPOSAL_BINDING_MISMATCH', 'independence_plan_mismatch');
|
|
1958
|
+
}
|
|
1959
|
+
if (witnessSet.project_id !== plan.project_id
|
|
1960
|
+
|| witnessSet.plan_key !== plan.plan_key
|
|
1961
|
+
|| witnessSet.witness_set_digest !== independenceArtifact.witness_set_digest) {
|
|
1962
|
+
compileFail('SEAM_PROPOSAL_BINDING_MISMATCH', 'witness_independence_mismatch');
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
const request = synthesizeWitnessRunRequest(witnessSet, {
|
|
1966
|
+
baseSha: independenceArtifact.base_sha,
|
|
1967
|
+
requestId: `seam-proposal-${independenceArtifact.result_digest.slice(0, 24)}`,
|
|
1968
|
+
});
|
|
1969
|
+
const components = conflictComponents(independenceArtifact);
|
|
1970
|
+
const concern = resolveConcernAnchors({
|
|
1971
|
+
manualWitness: witnessSet.manual_witness,
|
|
1972
|
+
taskIds: [...new Set(components.flatMap(({ task_ids: ids }) => ids))].sort(compareText),
|
|
1973
|
+
evidence,
|
|
1974
|
+
});
|
|
1975
|
+
const decisions = components.map((component) => (
|
|
1976
|
+
compileSeamProposalDecision({
|
|
1977
|
+
component,
|
|
1978
|
+
request,
|
|
1979
|
+
sensorEvidence,
|
|
1980
|
+
evidence,
|
|
1981
|
+
rawCollected,
|
|
1982
|
+
concernAnchors: concern.anchorsByTask,
|
|
1983
|
+
// このcomponentのtaskに関する解決失敗だけを持ち込む。
|
|
1984
|
+
concernUnknowns: concern.unknowns.filter((unknown) => (
|
|
1985
|
+
component.task_ids.some((taskId) => unknown.ref.startsWith(`${taskId}:`)
|
|
1986
|
+
|| unknown.ref.split(':')[0].split(',').includes(taskId))
|
|
1987
|
+
)),
|
|
1988
|
+
})
|
|
1989
|
+
)).sort((left, right) => compareText(left.component_id, right.component_id));
|
|
1990
|
+
const artifact = {
|
|
1991
|
+
schema: SEAM_PROPOSAL_SCHEMA,
|
|
1992
|
+
project_id: plan.project_id,
|
|
1993
|
+
plan_key: plan.plan_key,
|
|
1994
|
+
source_binding: {
|
|
1995
|
+
independence_schema: independenceArtifact.schema,
|
|
1996
|
+
independence_result_digest: independenceArtifact.result_digest,
|
|
1997
|
+
witness_set_digest: independenceArtifact.witness_set_digest,
|
|
1998
|
+
plan_version: independenceArtifact.plan_version,
|
|
1999
|
+
topology_digest: independenceArtifact.topology_digest,
|
|
2000
|
+
base_sha: independenceArtifact.base_sha,
|
|
2001
|
+
},
|
|
2002
|
+
compiled_at: compiledAt,
|
|
2003
|
+
decisions,
|
|
2004
|
+
result_digest: '',
|
|
2005
|
+
};
|
|
2006
|
+
artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
|
|
2007
|
+
if (!validateSeamProposal(artifact)) {
|
|
2008
|
+
compileFail('SEAM_PROPOSAL_ARTIFACT_INVALID', 'seam_proposal_artifact_invalid');
|
|
2009
|
+
}
|
|
2010
|
+
return artifact;
|
|
2011
|
+
}
|