@quolu/lattice 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +11 -1
- package/README.md +11 -2
- package/bin/lattice.mjs +12 -1
- package/package.json +1 -1
- package/sensor/dist/bin/lattice-sensor.js +72 -4
- package/sensor/dist/db/migrations.d.ts +1 -1
- package/sensor/dist/db/migrations.d.ts.map +1 -1
- package/sensor/dist/db/migrations.js +32 -2
- package/sensor/dist/db/migrations.js.map +1 -1
- package/sensor/dist/db/queries.d.ts +10 -0
- package/sensor/dist/db/queries.d.ts.map +1 -1
- package/sensor/dist/db/queries.js +53 -7
- package/sensor/dist/db/queries.js.map +1 -1
- package/sensor/dist/db/schema.sql +6 -1
- package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/sensor/dist/extraction/tree-sitter.js +89 -16
- package/sensor/dist/extraction/tree-sitter.js.map +1 -1
- package/sensor/dist/index.d.ts +8 -1
- package/sensor/dist/index.d.ts.map +1 -1
- package/sensor/dist/index.js +9 -0
- package/sensor/dist/index.js.map +1 -1
- package/sensor/dist/resolution/import-resolver.d.ts +11 -0
- package/sensor/dist/resolution/import-resolver.d.ts.map +1 -1
- package/sensor/dist/resolution/import-resolver.js +13 -5
- package/sensor/dist/resolution/import-resolver.js.map +1 -1
- package/sensor/dist/resolution/index.d.ts.map +1 -1
- package/sensor/dist/resolution/index.js +11 -0
- package/sensor/dist/resolution/index.js.map +1 -1
- package/sensor/dist/resolution/types.d.ts +4 -0
- package/sensor/dist/resolution/types.d.ts.map +1 -1
- package/sensor/dist/types.d.ts +16 -0
- package/sensor/dist/types.d.ts.map +1 -1
- package/src/artifact-contracts.mjs +3 -0
- package/src/cli-help.mjs +6 -0
- package/src/rc3-scripted-campaign.mjs +3 -1
- package/src/runtime-cli.mjs +280 -36
- package/src/runtime-contracts.mjs +1 -1
- package/src/runtime-control-store.mjs +24 -1
- package/src/runtime-decision-verifier.mjs +1 -1
- package/src/runtime-diff-observer.mjs +2 -2
- package/src/runtime-front-end.mjs +47 -13
- package/src/runtime-hold-recompile.mjs +31 -2
- package/src/runtime-io-sentinel.mjs +16 -9
- package/src/runtime-managed-supervisor.mjs +9 -1
- package/src/runtime-multi-epoch-store.mjs +2 -2
- package/src/runtime-projection.mjs +27 -0
- package/src/runtime-scripted-adapter-controller.mjs +22 -1
- package/src/runtime-seam-resolve.mjs +156 -13
- package/src/runtime-seam-treatment.mjs +2 -1
- package/src/seam-apply.mjs +101 -4
- package/src/seam-cost.mjs +322 -0
- package/src/seam-gate.mjs +146 -0
- package/src/seam-rewrite.mjs +102 -16
- package/src/seam-verification.mjs +26 -3
- package/src/sensor-adapter.mjs +6 -1
- package/src/todo-cli.mjs +55 -0
|
@@ -28,7 +28,7 @@ import { selfDigest } from './runtime-contracts.mjs';
|
|
|
28
28
|
import { coveredBy } from './runtime-diff-observer.mjs';
|
|
29
29
|
|
|
30
30
|
/** 警報の種別。findingのkindとは別空間にする——findingへ昇格するのはprobeを通った後だけである。 */
|
|
31
|
-
export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', '
|
|
31
|
+
export const IO_WARNING_KINDS = Object.freeze(['io_overlap_warning', 'io_undeclared_write_warning']);
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* 監視から外すrepo相対prefix。
|
|
@@ -65,7 +65,7 @@ export function isExcludedPath(relativePath, excludes = DEFAULT_IO_EXCLUDES) {
|
|
|
65
65
|
*
|
|
66
66
|
* checkpoint findingの2述語をそのまま1 pathへ適用する:
|
|
67
67
|
* - 他のrunning TODOの宣言scopeに入るpathへ書いた → `io_overlap_warning`
|
|
68
|
-
* - 自分の宣言scopeの外へ書いた → `
|
|
68
|
+
* - 自分の宣言scopeの外へ書いた → `io_undeclared_write_warning`
|
|
69
69
|
*
|
|
70
70
|
* @returns {{warnings: Array<{kind: string, todo_ids: string[], path: string}>}}
|
|
71
71
|
*/
|
|
@@ -84,7 +84,7 @@ export function classifyIoObservation(options = {}) {
|
|
|
84
84
|
|
|
85
85
|
const warnings = [];
|
|
86
86
|
if (!coveredBy(packet.scope.writes, relativePath)) {
|
|
87
|
-
warnings.push({ kind: '
|
|
87
|
+
warnings.push({ kind: 'io_undeclared_write_warning', todo_ids: [todoId], path: relativePath });
|
|
88
88
|
}
|
|
89
89
|
for (const otherId of [...runningTodoIds].sort(compareText)) {
|
|
90
90
|
if (otherId === todoId) continue;
|
|
@@ -298,15 +298,22 @@ export function probeIoWarning({ warning, checkpointsByTodo } = {}) {
|
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
/**
|
|
301
|
-
* 警報kind → finding kind
|
|
301
|
+
* 警報kind → finding kind。**重なりだけがhold経路へ乗る。**
|
|
302
302
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
303
|
+
* 宣言境界は計画時の**予測**であって、workerを閉じ込める制約ではない。範囲内へ無理に
|
|
304
|
+
* 押し込めるとworkerの自由度が落ち、成果の品質が下がる。だから自由に書かせ、**実際の足跡が
|
|
305
|
+
* 他の走行中TODOとぶつかった時にだけ**止めて処置する——請求項7(片方を停止し他方を確定して
|
|
306
|
+
* 再開する)と請求項8(限定的な変換を施して双方再開する)はそのための構成である。
|
|
307
|
+
*
|
|
308
|
+
* よって単独のscope警報——誰の領分とも重なっていない宣言外の書き込み——はhold経路へ運ばない。
|
|
309
|
+
* それは競合ではなく、**予測が実態より狭かったという情報**であり、止める理由が無い。記録は
|
|
310
|
+
* 残る(`io_warning_observed`)ので、再計画の材料としては失われない。
|
|
311
|
+
*
|
|
312
|
+
* 止めるべきでないものを止めると、処置の当てようが無い停止が生まれる。scope違反に処置が
|
|
313
|
+
* 無いのは欠落ではなく、処置すべき事象ではないことの現れである。
|
|
306
314
|
*/
|
|
307
315
|
const WARNING_FINDING_KIND = Object.freeze({
|
|
308
316
|
io_overlap_warning: 'observed_write_conflict',
|
|
309
|
-
io_scope_warning: 'scope_violation',
|
|
310
317
|
});
|
|
311
318
|
|
|
312
319
|
/**
|
|
@@ -320,7 +327,7 @@ function selectEscalationAnchor({ warning, writers, packets }) {
|
|
|
320
327
|
const wrote = new Set(writers);
|
|
321
328
|
const qualified = [...warning.todo_ids].sort(compareText).filter((todoId) => {
|
|
322
329
|
if (!wrote.has(todoId)) return false;
|
|
323
|
-
if (warning.kind === '
|
|
330
|
+
if (warning.kind === 'io_undeclared_write_warning') return true;
|
|
324
331
|
return warning.todo_ids.some((otherId) => {
|
|
325
332
|
if (otherId === todoId) return false;
|
|
326
333
|
const other = packets[otherId];
|
|
@@ -1340,7 +1340,15 @@ if (isDirectDaemon) {
|
|
|
1340
1340
|
commitPhaseRevision: (revision) => applyPhaseTodoRevision({
|
|
1341
1341
|
repoRoot: path.resolve(runDir, '..', '..', '..'),
|
|
1342
1342
|
writer: createTodoStoreWriter({ caller: 'g5-authoring' }), revision,
|
|
1343
|
-
actor
|
|
1343
|
+
// actorは`{host, session, agent}`のexact recordである(`lattice.todo_event.v4`)。
|
|
1344
|
+
// 文字列を渡していた間、seam_splitの再計画はphase revisionをcommitできなかった
|
|
1345
|
+
// ——工程storeへ書く直前で必ず落ちるので、請求項8の再開まで一度も届いていない。
|
|
1346
|
+
actor: {
|
|
1347
|
+
host: 'lattice-runtime',
|
|
1348
|
+
session: sessionNonce.slice(0, 32),
|
|
1349
|
+
agent: 'lattice-runtime-supervisor',
|
|
1350
|
+
},
|
|
1351
|
+
recordedAt: new Date().toISOString(),
|
|
1344
1352
|
}),
|
|
1345
1353
|
});
|
|
1346
1354
|
})
|
|
@@ -83,7 +83,7 @@ export function validateRuntimeFindingRecord(value) {
|
|
|
83
83
|
|| new Set(finding.evidence_digests).size !== finding.evidence_digests.length
|
|
84
84
|
|| finding.evidence_digests.some((entry, index) => index > 0 && finding.evidence_digests[index - 1] >= entry)
|
|
85
85
|
|| !selfDigestValid(finding, 'finding_digest')) return false;
|
|
86
|
-
const pathKind = ['observed_write_conflict', '
|
|
86
|
+
const pathKind = ['observed_write_conflict', 'undeclared_write', 'stale_context'].includes(finding.kind);
|
|
87
87
|
if (pathKind !== (finding.path !== null) || pathKind === (finding.resource_id !== null)) return false;
|
|
88
88
|
const observer = value.recorded_by;
|
|
89
89
|
return exactRecord(observer, ['schema', 'kind', 'controller_registration_digest',
|
|
@@ -109,7 +109,7 @@ export function validateRuntimeFindingCandidate(value) {
|
|
|
109
109
|
|| value.evidence_digests.some((digest, index) => index > 0
|
|
110
110
|
&& value.evidence_digests[index - 1] >= digest)
|
|
111
111
|
|| !selfDigestValid(value, 'candidate_digest')) return false;
|
|
112
|
-
const pathKind = ['observed_write_conflict', '
|
|
112
|
+
const pathKind = ['observed_write_conflict', 'undeclared_write', 'stale_context']
|
|
113
113
|
.includes(value.proposed_kind);
|
|
114
114
|
return pathKind
|
|
115
115
|
? typeof value.path === 'string' && value.path.length > 0 && value.resource_id === null
|
|
@@ -238,5 +238,32 @@ export function projectRuntimeStatusOverlays(options = {}) {
|
|
|
238
238
|
carry_over: members('carry_over'),
|
|
239
239
|
redispatch: members('redispatch'),
|
|
240
240
|
intake_frozen: runtimeState.freeze !== null,
|
|
241
|
+
treatment_advice: treatmentAdvice(events),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 止まっているrunに対して、請求項8(変換)を試せるかを助言する。
|
|
247
|
+
*
|
|
248
|
+
* 拒否ではなく助言である(ADR 0128と同じ規律)。既定の再計画modeが`intentional_serial`だから
|
|
249
|
+
* といって「変換が不可能」ではない——事前宣言が無いだけである。実行時に初めて見つかった競合には
|
|
250
|
+
* 事前宣言が無いので、既定だけを見ると請求項8へ行く道が無いように見える。
|
|
251
|
+
*
|
|
252
|
+
* 装置が言えるのは**係争資源が切れる種類か**までとする。共有stateや外部effectは面を分けても
|
|
253
|
+
* 同じ資源に触り続けるので切れない。pathは分けられる**かもしれない**——実際に切れるかと、
|
|
254
|
+
* それが割に合うかは、隔離worktreeで変換して五条件で測って初めて分かる(`run seam resolve`)。
|
|
255
|
+
* 試すかどうかは費用のかかる判断なので、装置は決めずに材料だけ渡す。
|
|
256
|
+
*/
|
|
257
|
+
function treatmentAdvice(events) {
|
|
258
|
+
const conflict = [...events].reverse()
|
|
259
|
+
.find((event) => event.kind === 'conflict_found'
|
|
260
|
+
&& event.payload !== null && typeof event.payload === 'object');
|
|
261
|
+
if (conflict === undefined) return null;
|
|
262
|
+
const severability = typeof conflict.payload.path === 'string' ? 'code_seam' : 'serial';
|
|
263
|
+
return {
|
|
264
|
+
finding_digest: typeof conflict.payload.finding_digest === 'string'
|
|
265
|
+
? conflict.payload.finding_digest : null,
|
|
266
|
+
severability,
|
|
267
|
+
transform_attemptable: severability === 'code_seam',
|
|
241
268
|
};
|
|
242
269
|
}
|
|
@@ -511,6 +511,21 @@ export async function createScriptedAdapterController({
|
|
|
511
511
|
let registrationDigest = null;
|
|
512
512
|
|
|
513
513
|
/** dispatch応答が運ぶworker process。誰を止めればよいかをsupervisorへ名指しする。 */
|
|
514
|
+
/**
|
|
515
|
+
* 起こしたworker processを畳む。
|
|
516
|
+
*
|
|
517
|
+
* **SIGKILLでなければ届かない。** barrierで止めたworkerはSIGSTOP状態にあり、
|
|
518
|
+
* SIGTERMは継続されるまで配送されない。放置すると、runもcontrollerもrepoも消えた後まで
|
|
519
|
+
* 停止したままのprocessが残り続ける(自分では終われない)。
|
|
520
|
+
*/
|
|
521
|
+
const reapWorkers = () => {
|
|
522
|
+
for (const task of tasks.values()) {
|
|
523
|
+
const pid = task.worker?.pid;
|
|
524
|
+
if (!Number.isSafeInteger(pid)) continue;
|
|
525
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* 既に終わっている */ }
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
514
529
|
const workerProcessOf = (task) => ({
|
|
515
530
|
pid: task.worker.pid,
|
|
516
531
|
process_group_id: task.worker.process_group_id,
|
|
@@ -904,7 +919,9 @@ export async function createScriptedAdapterController({
|
|
|
904
919
|
worktree_id: rebind.worktree_id,
|
|
905
920
|
predecessor_epoch: rebind.new_plan_epoch - 1,
|
|
906
921
|
successor_epoch: rebind.new_plan_epoch,
|
|
907
|
-
|
|
922
|
+
// **holdされたworkerにreceiptは無い。** 作業を終えていないから止められているので、
|
|
923
|
+
// 完了の記録を前提にできない。前任packetのdigestはdispatch時のpacketが持っている。
|
|
924
|
+
predecessor_packet_digest: task.packet.packet_digest,
|
|
908
925
|
rebind_packet_digest: rebind.packet_digest,
|
|
909
926
|
new_write_lease_id: staged.lease_id,
|
|
910
927
|
supervisor_session_nonce_digest: sessionNonceDigest,
|
|
@@ -947,6 +964,7 @@ export async function createScriptedAdapterController({
|
|
|
947
964
|
controllerSessionNonce,
|
|
948
965
|
descriptor: structuredClone(descriptor),
|
|
949
966
|
heartbeat,
|
|
967
|
+
reapWorkers,
|
|
950
968
|
leaseSetDigest() {
|
|
951
969
|
return digestArtifact([...stagedLeases.keys(), ...armedLeases.keys()].sort());
|
|
952
970
|
},
|
|
@@ -1102,6 +1120,9 @@ export async function runScriptedAdapterController({
|
|
|
1102
1120
|
const close = async () => {
|
|
1103
1121
|
if (closed) return;
|
|
1104
1122
|
closed = true;
|
|
1123
|
+
// 起こしたworkerを道連れにする。停止中のworkerは自分では終われないので、
|
|
1124
|
+
// ここで畳まないとrunもrepoも消えた後まで残り続ける。
|
|
1125
|
+
controller.reapWorkers();
|
|
1105
1126
|
clearInterval(heartbeatTimer);
|
|
1106
1127
|
persistentSocket?.destroy();
|
|
1107
1128
|
await new Promise((resolve) => server.close(resolve));
|
|
@@ -19,11 +19,16 @@ import { digestArtifact } from './artifact-contracts.mjs';
|
|
|
19
19
|
import { commitSeamTransform } from './seam-commit.mjs';
|
|
20
20
|
import { applySeamConflict } from './seam-apply.mjs';
|
|
21
21
|
import { resolveRuntimeSeamTreatment } from './runtime-seam-treatment.mjs';
|
|
22
|
+
import { affectedTestsFromEvidence } from './runtime-front-end.mjs';
|
|
23
|
+
import { explainSeamGate } from './seam-gate.mjs';
|
|
22
24
|
import { collectWitnessSensorEvidence, compileTodoIndependence } from './todo-independence.mjs';
|
|
23
25
|
import { todoSelfDigest } from './todo-contracts.mjs';
|
|
24
26
|
|
|
25
27
|
export const RUNTIME_SEAM_REQUEST_SCHEMA = 'lattice.runtime_seam_request.v1';
|
|
26
|
-
|
|
28
|
+
// v2は翻訳段(`reconciled`)の追加である。宣言を観測へ合わせてから判定するようになったので、
|
|
29
|
+
// どの宣言の上で五条件を見たかが決着の一部になった。v1の形のまま中身を変えると、記録が何に
|
|
30
|
+
// ついてのものか確定しなくなる。
|
|
31
|
+
export const RUNTIME_SEAM_RESOLUTION_SCHEMA = 'lattice.runtime_seam_resolution.v2';
|
|
27
32
|
|
|
28
33
|
/** 合成するtodo planのkey。実行時planとは別空間なので固定でよい。 */
|
|
29
34
|
const SYNTHETIC_PLAN_KEY = 'runtime';
|
|
@@ -77,21 +82,130 @@ export function validateRuntimeSeamRequest(value) {
|
|
|
77
82
|
return value.request_digest === todoSelfDigest(value, 'request_digest');
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 観測された実態へ宣言を合わせる(翻訳段)。
|
|
87
|
+
*
|
|
88
|
+
* 実行時のpath競合は、**片方がその資源を所有していないから起きる**。所有していない資源の内側に
|
|
89
|
+
* 担当は主張できない(`concern_anchor_resource_not_owned`)ので、宣言のままでは変換の入力が
|
|
90
|
+
* 組めない——請求項8は、実行時に見つかった競合の形をそのままでは受け取れなかった。
|
|
91
|
+
*
|
|
92
|
+
* 境界は計画時の**予測**であって、workerを閉じ込める制約ではない。予測を超えたのは予測が狭かった
|
|
93
|
+
* からであり、作業が不正だったからではない。したがって操作は「破った側を直す」ではなく、観測が
|
|
94
|
+
* 示した実態へ宣言を合わせることであり、**対称**である——どちらの足跡が予測を超えたかは観測が
|
|
95
|
+
* 決めるので、ここでは関与TODOを同じ規則で扱う。
|
|
96
|
+
*
|
|
97
|
+
* これは3つ目の処置ではない。翻訳を通ると計画時競合の形(`owns`の交差)になり、そこから先は
|
|
98
|
+
* 既存の請求項7/8がそのまま適用できる。競合辺が立つのは翻訳の副産物ではなく目的である——
|
|
99
|
+
* 立たないままだと`overlap_reduced`が最初から満たされたことになり、変換の検証が無意味になる。
|
|
100
|
+
*
|
|
101
|
+
* 広げるのは観測が示した資源だけとする。findingが持つ係争pathも関与TODOも観測であって推定では
|
|
102
|
+
* ない。広げた事実は呼び出し側へ返す。黙って広げると、予測が外れたことも、判定がどの宣言の上で
|
|
103
|
+
* 行われたかも残らない。
|
|
104
|
+
*
|
|
105
|
+
* **所有の宣言は裏取りと対で広げる。** `owns`だけ足すとその資源は`sensor_unbound`になり、compileは
|
|
106
|
+
* 非dispatchable(`BOUNDARY_UNKNOWN`)へ落ちる。そこでは競合が投影されないので、翻訳したのに
|
|
107
|
+
* 競合辺が立たず、変換の便益が測れない。裏取りに使えるqueryがrun のquery setに無ければ、
|
|
108
|
+
* 広げずに理由を返す——証明できない宣言を作らない。
|
|
109
|
+
*/
|
|
110
|
+
export function reconcileWitnessToObservation({
|
|
111
|
+
manualWitness, contestedPath, todoIds, sensorQuerySet = null, observedAffectedTests = null,
|
|
112
|
+
} = {}) {
|
|
113
|
+
const reconciled = {};
|
|
114
|
+
const widened = [];
|
|
115
|
+
const reject = (reason) => ({ manualWitness: null, widened: [], reasons: [reason] });
|
|
116
|
+
// 係争pathを覆えるqueryを、run のquery setから拾う。無ければ翻訳しない。
|
|
117
|
+
//
|
|
118
|
+
// `affected`だけを見る。path所有を裏取りするのはこのoperationであり、構造query(query/callers/
|
|
119
|
+
// callees/impact)はsymbolを的にする。構造queryがたまたま同じ文字列をtargetに持つからといって
|
|
120
|
+
// 所有の裏取りへ流用すると、束縛の意味が変わる。
|
|
121
|
+
const covering = (sensorQuerySet?.queries ?? [])
|
|
122
|
+
.filter((query) => query.operation === 'affected' && query.target === contestedPath)
|
|
123
|
+
.map((query) => query.id)
|
|
124
|
+
.sort(compareText);
|
|
125
|
+
|
|
126
|
+
for (const todoId of todoIds) {
|
|
127
|
+
const witness = manualWitness?.[todoId];
|
|
128
|
+
if (!plainObject(witness)) return reject(`witness_absent:${todoId}`);
|
|
129
|
+
const next = structuredClone(witness);
|
|
130
|
+
const ownsPath = (next.owns ?? [])
|
|
131
|
+
.some((own) => own.kind === 'path' && own.target === contestedPath);
|
|
132
|
+
const writesPath = (next.writes ?? []).includes(contestedPath);
|
|
133
|
+
const boundPath = (next.sensor_provenance?.queries ?? [])
|
|
134
|
+
.some((entry) => (entry.expect?.kind === 'affected' || entry.expect?.kind === 'path')
|
|
135
|
+
&& entry.expect?.path === contestedPath);
|
|
136
|
+
// 既に所有と書き込みを宣言しているなら、観測は予測の内側にある。合わせるものが無いので
|
|
137
|
+
// 触らない——裏取りが足りているかどうかは、その宣言を書いた側の問題であり、compileが見る。
|
|
138
|
+
// 翻訳が手を入れてよいのは、観測が予測を超えた分だけである。
|
|
139
|
+
if (ownsPath && writesPath) {
|
|
140
|
+
reconciled[todoId] = next;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!boundPath) {
|
|
144
|
+
if (covering.length === 0) return reject(`observation_unbacked:${todoId}:${contestedPath}`);
|
|
145
|
+
// 同じ資源は同じqueryで裏取りする。TODOごとに別のqueryを選ぶと、front-endが被覆の
|
|
146
|
+
// 曖昧さとして弾く。
|
|
147
|
+
if (covering.length > 1) return reject(`observation_binding_ambiguous:${contestedPath}`);
|
|
148
|
+
// 観測できていないaffectedを推測で埋めない。
|
|
149
|
+
if (!Array.isArray(observedAffectedTests)) {
|
|
150
|
+
return reject(`observation_affected_unread:${contestedPath}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// 所有・書き込み・裏取りをまとめて足す。観測されたのは「このpathへの書き込み」であり、
|
|
154
|
+
// 宣言の一部だけを合わせると、宣言が実態からずれたまま次の判定の前提になる。`creates`は
|
|
155
|
+
// 付けない——観測できたのはpathが既に在るからである。
|
|
156
|
+
if (!ownsPath) next.owns = [...next.owns, { kind: 'path', target: contestedPath }]
|
|
157
|
+
.sort((left, right) => compareText(`${left.kind}\0${left.target}`, `${right.kind}\0${right.target}`));
|
|
158
|
+
if (!writesPath) next.writes = [...next.writes, contestedPath].sort(compareText);
|
|
159
|
+
if (!boundPath) {
|
|
160
|
+
next.sensor_provenance = {
|
|
161
|
+
...next.sensor_provenance,
|
|
162
|
+
queries: [...next.sensor_provenance.queries,
|
|
163
|
+
{ query_id: covering[0], expect: { kind: 'affected', path: contestedPath } }],
|
|
164
|
+
};
|
|
165
|
+
// 面を1つ引き受けたら、その面のaffected testも引き受ける。宣言と観測はTODO単位で
|
|
166
|
+
// exact一致を要求されるので、片方だけ広げるとdriftになる。
|
|
167
|
+
next.affected_tests = [...new Set([...next.affected_tests, ...observedAffectedTests])]
|
|
168
|
+
.sort(compareText);
|
|
169
|
+
}
|
|
170
|
+
reconciled[todoId] = next;
|
|
171
|
+
widened.push({
|
|
172
|
+
todo_id: todoId,
|
|
173
|
+
resource: { kind: 'path', target: contestedPath },
|
|
174
|
+
fields: [
|
|
175
|
+
...(ownsPath ? [] : ['owns']),
|
|
176
|
+
...(writesPath ? [] : ['writes']),
|
|
177
|
+
...(boundPath ? [] : ['affected_tests', 'sensor_provenance']),
|
|
178
|
+
].sort(compareText),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return { manualWitness: reconciled, widened, reasons: [] };
|
|
182
|
+
}
|
|
183
|
+
|
|
80
184
|
/**
|
|
81
185
|
* 実行時witnessへconcern anchorを足してtodo witness setにする。
|
|
82
186
|
*
|
|
83
187
|
* 実行時のmanual_witnessはconcern_anchorsを持たない(`lattice.run_request.v3`)。持たせるのでなく、
|
|
84
188
|
* 宣言から足す——係争資源の中のどのsymbolを触るかは実行時に確定する情報であり、run開始時点の
|
|
85
189
|
* 契約に書けるものではないからである。
|
|
190
|
+
*
|
|
191
|
+
* anchorを足す前に宣言を観測へ合わせる(`reconcileWitnessToObservation`)。この順序でないと、
|
|
192
|
+
* 係争資源を所有していないTODOのanchorが必ず不正になる。
|
|
86
193
|
*/
|
|
87
|
-
export function buildRuntimeSeamWitnessSet({
|
|
194
|
+
export function buildRuntimeSeamWitnessSet({
|
|
195
|
+
request, declaration, contestedPath, executors, observedAffectedTests = null,
|
|
196
|
+
}) {
|
|
88
197
|
const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
|
|
198
|
+
const translated = reconcileWitnessToObservation({
|
|
199
|
+
manualWitness: request.manual_witness, contestedPath, todoIds,
|
|
200
|
+
sensorQuerySet: request.sensor_query_set, observedAffectedTests,
|
|
201
|
+
});
|
|
202
|
+
if (translated.manualWitness === null) {
|
|
203
|
+
return { witnessSet: null, widened: [], reasons: translated.reasons };
|
|
204
|
+
}
|
|
89
205
|
const manual = {};
|
|
90
206
|
for (const todoId of todoIds) {
|
|
91
|
-
const witness = request.manual_witness?.[todoId];
|
|
92
|
-
if (!plainObject(witness)) return { witnessSet: null, reasons: [`witness_absent:${todoId}`] };
|
|
93
207
|
manual[todoId] = {
|
|
94
|
-
...
|
|
208
|
+
...translated.manualWitness[todoId],
|
|
95
209
|
concern_anchors: [{
|
|
96
210
|
within: { kind: 'path', target: contestedPath },
|
|
97
211
|
symbols: [...declaration.concern_symbols[todoId]].sort(compareText),
|
|
@@ -108,7 +222,7 @@ export function buildRuntimeSeamWitnessSet({ request, declaration, contestedPath
|
|
|
108
222
|
witness_set_digest: '',
|
|
109
223
|
};
|
|
110
224
|
witnessSet.witness_set_digest = todoSelfDigest(witnessSet, 'witness_set_digest');
|
|
111
|
-
return { witnessSet, reasons: [] };
|
|
225
|
+
return { witnessSet, widened: translated.widened, reasons: [] };
|
|
112
226
|
}
|
|
113
227
|
|
|
114
228
|
function syntheticTodoPlan(todoIds) {
|
|
@@ -137,7 +251,7 @@ export async function resolveRuntimeSeam({
|
|
|
137
251
|
const todoIds = Object.keys(declaration.concern_symbols).sort(compareText);
|
|
138
252
|
|
|
139
253
|
if (finding.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
|
|
140
|
-
return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null };
|
|
254
|
+
return { lane: 'intentional_serial', reasons: ['finding_not_write_conflict'], split: null, widened: [] };
|
|
141
255
|
}
|
|
142
256
|
const findingTodoIds = [...finding.todo_ids].sort(compareText);
|
|
143
257
|
if (findingTodoIds.join('\0') !== todoIds.join('\0')) {
|
|
@@ -145,30 +259,41 @@ export async function resolveRuntimeSeam({
|
|
|
145
259
|
lane: 'intentional_serial',
|
|
146
260
|
reasons: ['declared_todos_differ_from_finding'],
|
|
147
261
|
split: null,
|
|
262
|
+
widened: [],
|
|
148
263
|
};
|
|
149
264
|
}
|
|
150
265
|
|
|
266
|
+
// sensorは1回だけ引く。翻訳(宣言を観測へ合わせる)とcompileは同じ観測の上で行う——
|
|
267
|
+
// 別々に引くと、翻訳が見た実態とcompileが見た実態がずれうる。
|
|
268
|
+
const sensorEvidence = await collectWitnessSensorEvidence({
|
|
269
|
+
cwd: repoRoot, witnessSet: { sensor_query_set: request.sensor_query_set },
|
|
270
|
+
});
|
|
151
271
|
const built = buildRuntimeSeamWitnessSet({
|
|
152
272
|
request, declaration, contestedPath: finding.path,
|
|
153
273
|
executors: request.capacity.executors,
|
|
274
|
+
observedAffectedTests: affectedTestsFromEvidence({
|
|
275
|
+
sensorEvidence, querySet: request.sensor_query_set, path: finding.path,
|
|
276
|
+
}),
|
|
154
277
|
});
|
|
155
278
|
if (built.witnessSet === null) {
|
|
156
|
-
return { lane: 'intentional_serial', reasons: built.reasons, split: null };
|
|
279
|
+
return { lane: 'intentional_serial', reasons: built.reasons, split: null, widened: [] };
|
|
157
280
|
}
|
|
158
281
|
const witnessSet = built.witnessSet;
|
|
159
282
|
const plan = syntheticTodoPlan(todoIds);
|
|
160
283
|
|
|
161
|
-
// 観測したaffected tests
|
|
284
|
+
// 観測したaffected testsだけを検証に使う。宣言から発明しない。翻訳後の宣言から採る——
|
|
285
|
+
// 所有が広がったTODOは、その面のtestも自分のaffectedとして引き受けている。
|
|
162
286
|
const affectedTests = [...new Set(todoIds
|
|
163
|
-
.flatMap((todoId) =>
|
|
287
|
+
.flatMap((todoId) => witnessSet.manual_witness[todoId].affected_tests))].sort(compareText);
|
|
164
288
|
|
|
165
289
|
const baseArtifact = compileTodoIndependence({
|
|
166
|
-
witnessSet, plan, baseSha, compiledAt,
|
|
167
|
-
sensorEvidence: await collectWitnessSensorEvidence({ cwd: repoRoot, witnessSet }),
|
|
290
|
+
witnessSet, plan, baseSha, compiledAt, sensorEvidence,
|
|
168
291
|
});
|
|
169
292
|
|
|
170
293
|
const pathNames = { ...declaration.path_names };
|
|
171
|
-
|
|
294
|
+
// 翻訳で広げた宣言は決着へ載せる。どの宣言の上で五条件を判定したかが残らないと、
|
|
295
|
+
// 「変換して通った」という記録が何についてのものか確定しない。
|
|
296
|
+
const resolved = await resolveRuntimeSeamTreatment({
|
|
172
297
|
finding,
|
|
173
298
|
witnessSet,
|
|
174
299
|
pathNames,
|
|
@@ -176,6 +301,8 @@ export async function resolveRuntimeSeam({
|
|
|
176
301
|
manifestDigest: baseArtifact.result_digest,
|
|
177
302
|
affectedTests,
|
|
178
303
|
taskMigrationDigest: declaration.task_migration_digest,
|
|
304
|
+
// storeへ記録されたfindingのidで縛る。再計画側はこのidでfinding recordを読む。
|
|
305
|
+
recordedFindingDigest: findingRecord.finding_digest,
|
|
179
306
|
commitTransform: async ({ files, candidateId }) => commitSeamTransform({
|
|
180
307
|
repoRoot, baseSha, files, candidateId,
|
|
181
308
|
}),
|
|
@@ -205,6 +332,7 @@ export async function resolveRuntimeSeam({
|
|
|
205
332
|
return { ...applied, candidate: applied.candidate ?? null };
|
|
206
333
|
},
|
|
207
334
|
});
|
|
335
|
+
return { ...resolved, widened: built.widened };
|
|
208
336
|
}
|
|
209
337
|
|
|
210
338
|
/** 決着をartifactにする。branchを動かすのは操作するAIなので、行き先を明示して返す。 */
|
|
@@ -215,6 +343,21 @@ export function buildRuntimeSeamResolution({ runId, findingDigest, resolved }) {
|
|
|
215
343
|
finding_digest: findingDigest,
|
|
216
344
|
lane: resolved.lane,
|
|
217
345
|
reasons: [...resolved.reasons].sort(compareText),
|
|
346
|
+
// 確実の門(sc-012)。拒否理由を「宣言を直せば機械で通る」「AIが変換すべき」へ分類し、
|
|
347
|
+
// 次に誰が動くべきかを事実として返す。可否は決めない。
|
|
348
|
+
gate: explainSeamGate(resolved.reasons ?? []),
|
|
349
|
+
// 判定の前に宣言をどれだけ観測へ合わせたか。空配列は「予測が実態を覆っていた」という
|
|
350
|
+
// 意味であり、翻訳しなかったことと区別できる。
|
|
351
|
+
reconciled: [...(resolved.widened ?? [])]
|
|
352
|
+
.map((entry) => ({
|
|
353
|
+
todo_id: entry.todo_id,
|
|
354
|
+
resource: { kind: entry.resource.kind, target: entry.resource.target },
|
|
355
|
+
fields: [...entry.fields].sort(compareText),
|
|
356
|
+
}))
|
|
357
|
+
.sort((left, right) => compareText(
|
|
358
|
+
`${left.todo_id}\0${left.resource.kind}\0${left.resource.target}`,
|
|
359
|
+
`${right.todo_id}\0${right.resource.kind}\0${right.resource.target}`,
|
|
360
|
+
)),
|
|
218
361
|
split: resolved.split ?? null,
|
|
219
362
|
successor_base_sha: resolved.successor_base_sha ?? null,
|
|
220
363
|
successor_base_ref: resolved.successor_base_ref ?? null,
|
|
@@ -99,7 +99,7 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
|
99
99
|
const {
|
|
100
100
|
finding, witnessSet, pathNames = {}, predeclaredTreatments = [],
|
|
101
101
|
applyConflict, commitTransform, baseSha, manifestDigest, affectedTests = [],
|
|
102
|
-
taskMigrationDigest,
|
|
102
|
+
taskMigrationDigest, recordedFindingDigest = null,
|
|
103
103
|
} = options;
|
|
104
104
|
|
|
105
105
|
if (finding?.kind === 'observed_write_conflict' && typeof finding.path === 'string') {
|
|
@@ -112,6 +112,7 @@ export async function resolveRuntimeSeamTreatment(options = {}) {
|
|
|
112
112
|
|
|
113
113
|
const { conflict, reasons } = seamConflictFromFinding({
|
|
114
114
|
finding, witnessSet, pathNames, affectedTests, baseSha, manifestDigest,
|
|
115
|
+
recordedFindingDigest,
|
|
115
116
|
});
|
|
116
117
|
if (conflict === null) {
|
|
117
118
|
return { lane: 'intentional_serial', treatment: null, split: null, reasons };
|
package/src/seam-apply.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { runIsolatedTransform } from './isolation-runner.mjs';
|
|
|
14
14
|
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
15
15
|
import { invokeSensorCli } from './sensor-runtime.mjs';
|
|
16
16
|
import { buildSeamDerivationQuerySet, deriveBoundedSeamCandidate } from './seam-derivation.mjs';
|
|
17
|
-
import { planSeamRewrite } from './seam-rewrite.mjs';
|
|
17
|
+
import { joinImportSurface, mentions, planSeamRewrite } from './seam-rewrite.mjs';
|
|
18
18
|
import {
|
|
19
19
|
buildPostTransformWitnessSet, compareExportSurface, evaluateSeamVerification, measureWaveCount,
|
|
20
20
|
} from './seam-verification.mjs';
|
|
@@ -112,13 +112,38 @@ export async function readSymbolExtents({ cwd, sourcePath, symbols }) {
|
|
|
112
112
|
const node = nodeOf(entry);
|
|
113
113
|
if (node === null || node.name !== symbol || node.filePath !== sourcePath) continue;
|
|
114
114
|
if (!Number.isSafeInteger(node.startLine) || !Number.isSafeInteger(node.endLine)) continue;
|
|
115
|
-
|
|
115
|
+
// 装飾込みの開始行(sensor v11)を優先する。Pythonの@decoratorやRustの#[derive]は
|
|
116
|
+
// 宣言の外の行にあり、宣言行だけで切ると装飾が残余面へ取り残される。
|
|
117
|
+
const start = Number.isSafeInteger(node.extentStartLine) && node.extentStartLine < node.startLine
|
|
118
|
+
? node.extentStartLine : node.startLine;
|
|
119
|
+
// export状態もAST事実として持ち帰る(sc-013)。書き換え側のtext走査を置き換える。
|
|
120
|
+
extents[symbol] = { startLine: start, endLine: node.endLine, isExported: node.isExported === true };
|
|
116
121
|
}
|
|
117
122
|
if (extents[symbol] === undefined && entries.length >= SYMBOL_LOOKUP_LIMIT) truncated.push(symbol);
|
|
118
123
|
}
|
|
119
124
|
return { extents, truncated: [...new Set(truncated)].sort(compareText) };
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 対象fileのimport面をsensorの観測から読む(sc-013)。
|
|
129
|
+
*
|
|
130
|
+
* `file-nodes`の`imports`(文の行範囲)と`import_bindings`(AST由来の束縛)を
|
|
131
|
+
* `joinImportSurface`で文単位へ束ねる。観測が取れなければnullを返し、書き換え側が
|
|
132
|
+
* `import_surface_missing`のtyped理由で止める——正規表現の再解析へfallbackしない。
|
|
133
|
+
*/
|
|
134
|
+
export function readImportSurface({ cwd, sourcePath }) {
|
|
135
|
+
const result = invokeSensorCli(
|
|
136
|
+
(command, args, options) => spawnSync(command, args, options),
|
|
137
|
+
['file-nodes', sourcePath, '--path', '.'],
|
|
138
|
+
{ cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
|
|
139
|
+
);
|
|
140
|
+
if (result.status !== 0) return null;
|
|
141
|
+
let parsed;
|
|
142
|
+
try { parsed = JSON.parse(result.stdout); } catch { return null; }
|
|
143
|
+
if (!Array.isArray(parsed?.imports) || !Array.isArray(parsed?.import_bindings)) return null;
|
|
144
|
+
return joinImportSurface(parsed.imports, parsed.import_bindings);
|
|
145
|
+
}
|
|
146
|
+
|
|
122
147
|
async function runIn(worktreePath, command, args) {
|
|
123
148
|
try {
|
|
124
149
|
const { stdout } = await execFileAsync(command, args, {
|
|
@@ -130,6 +155,66 @@ async function runIn(worktreePath, command, args) {
|
|
|
130
155
|
}
|
|
131
156
|
}
|
|
132
157
|
|
|
158
|
+
/**
|
|
159
|
+
* 変換で切断された参照を数える(検証網、ADR 0145)。
|
|
160
|
+
*
|
|
161
|
+
* 移した先のcodeが、残余面に留まったsymbol(module変数・非公開関数)へ束縛なしで言及して
|
|
162
|
+
* いれば、その参照は切断されている——moduleの読み込みは通り、実行して初めてReferenceErrorに
|
|
163
|
+
* なるので、focused testが当該経路を通らなければ黙って壊れたまま採用される。これを受入の
|
|
164
|
+
* 一点で数える。
|
|
165
|
+
*
|
|
166
|
+
* 残余面のsymbol一覧は、変換後worktreeのfresh indexから抽出精度で取る(`file-nodes`)。
|
|
167
|
+
* value-ref辺の名前フィルタはノード生成に効かないので、全小文字のmodule変数もここには載る。
|
|
168
|
+
* `unresolved_refs`は使わない——bare参照の切断はそこに記録されないことを実測で確認した
|
|
169
|
+
* (builtin呼び出しは載るが、未束縛のidentifier読みは載らない)。
|
|
170
|
+
*
|
|
171
|
+
* 検査は保守的である。`mentions`はtext一致なので、文字列やcomment内の同名語も
|
|
172
|
+
* 「切断の疑い」として数える——見逃す方向ではなく誤検出の方向へ倒す(fail closed)。
|
|
173
|
+
* 網は受入の一点だけで、過程には触れない。不認定は拒否ではなく、理由を見て直せば
|
|
174
|
+
* 何度でも再提出できる。
|
|
175
|
+
*/
|
|
176
|
+
async function detectSeveredReferences({ worktreePath, files, residualPath }) {
|
|
177
|
+
const readFileNodes = (target) => {
|
|
178
|
+
const result = invokeSensorCli(
|
|
179
|
+
(command, args, options) => spawnSync(command, args, options),
|
|
180
|
+
['file-nodes', target, '--path', '.'],
|
|
181
|
+
{ cwd: worktreePath, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 },
|
|
182
|
+
);
|
|
183
|
+
if (result.status !== 0) return null;
|
|
184
|
+
let parsed;
|
|
185
|
+
try { parsed = JSON.parse(result.stdout); } catch { return null; }
|
|
186
|
+
if (!Array.isArray(parsed?.nodes)) return null;
|
|
187
|
+
return parsed;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const residual = readFileNodes(residualPath);
|
|
191
|
+
if (residual === null) return { observed: false, entries: [] };
|
|
192
|
+
const residualNames = residual.nodes.map(({ name }) => name);
|
|
193
|
+
|
|
194
|
+
const entries = [];
|
|
195
|
+
for (const [target, body] of Object.entries(files)) {
|
|
196
|
+
if (target === residualPath) continue;
|
|
197
|
+
const own = readFileNodes(target);
|
|
198
|
+
if (own === null) return { observed: false, entries: [] };
|
|
199
|
+
const defined = new Set(own.nodes.map(({ name }) => name));
|
|
200
|
+
// import束縛はworktreeのfresh indexのAST観測から取る(sc-013)。text再解析をしない。
|
|
201
|
+
// 束縛が観測できないindexでは網の判定材料が欠けるので、unobservedへ倒す(fail closed)。
|
|
202
|
+
if (!Array.isArray(own.import_bindings)) return { observed: false, entries: [] };
|
|
203
|
+
const imported = new Set(own.import_bindings
|
|
204
|
+
.map(({ local }) => local).filter((name) => typeof name === 'string'));
|
|
205
|
+
for (const name of residualNames) {
|
|
206
|
+
if (defined.has(name) || imported.has(name)) continue;
|
|
207
|
+
if (mentions(body, name)) entries.push({ file: target, name });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
observed: true,
|
|
212
|
+
entries: entries.sort((left, right) => compareText(
|
|
213
|
+
`${left.file}\0${left.name}`, `${right.file}\0${right.name}`,
|
|
214
|
+
)),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
133
218
|
/**
|
|
134
219
|
* 変換後worktreeを再indexし、新pathが索引に載ったかを見る。
|
|
135
220
|
*
|
|
@@ -230,6 +315,7 @@ export function seamConflictFromProposal({ proposal, witnessSet, pathNames = {}
|
|
|
230
315
|
*/
|
|
231
316
|
export function seamConflictFromFinding({
|
|
232
317
|
finding, witnessSet, pathNames = {}, affectedTests = [], baseSha, manifestDigest,
|
|
318
|
+
recordedFindingDigest = null,
|
|
233
319
|
} = {}) {
|
|
234
320
|
if (finding?.kind !== 'observed_write_conflict' || typeof finding.path !== 'string') {
|
|
235
321
|
return { conflict: null, reasons: ['finding_not_write_conflict'] };
|
|
@@ -250,7 +336,11 @@ export function seamConflictFromFinding({
|
|
|
250
336
|
baseSha,
|
|
251
337
|
manifestDigest,
|
|
252
338
|
// 実行時は提案artifactが無い。観測したfindingそのものを出所として縛る。
|
|
253
|
-
|
|
339
|
+
//
|
|
340
|
+
// **記録済みfindingのdigestがあるなら、それを使う。** 内容から再導出したdigestで縛ると、
|
|
341
|
+
// 「この変換はあのfindingへの答えだ」という記録が、storeに実在しないidを指す。実際、
|
|
342
|
+
// 再計画側は`findings/<digest>.json`を読むので、再導出値では必ず読めない。
|
|
343
|
+
findingDigest: recordedFindingDigest ?? digestArtifact({
|
|
254
344
|
kind: finding.kind, path: finding.path, todo_ids: taskIds,
|
|
255
345
|
}),
|
|
256
346
|
candidateId: `seam-runtime-${sha16(`${finding.path}\0${taskIds.join(',')}`)}`,
|
|
@@ -314,6 +404,7 @@ export async function applySeamConflict({
|
|
|
314
404
|
}
|
|
315
405
|
const rewritten = planSeamRewrite({
|
|
316
406
|
sourceText: beforeText, candidate, symbolExtents: lookup.extents,
|
|
407
|
+
importSurface: readImportSurface({ cwd: repoRoot, sourcePath }),
|
|
317
408
|
});
|
|
318
409
|
if (rewritten.files === null) {
|
|
319
410
|
return { outcome: outcome({ planKey, decision: 'rejected', reasons: rewritten.reasons, candidate }), files: null };
|
|
@@ -361,9 +452,14 @@ export async function applySeamConflict({
|
|
|
361
452
|
const owned = candidate.surfaces
|
|
362
453
|
.filter(({ role }) => role === 'task_owned').map(({ path: target }) => target);
|
|
363
454
|
const sensor = await observeFreshSensor({ worktreePath, latticeBin, paths: owned });
|
|
364
|
-
observation = { sensor, afterText: null, afterArtifact: null };
|
|
455
|
+
observation = { sensor, afterText: null, afterArtifact: null, severed: null };
|
|
365
456
|
observation.afterText = await readFile(path.join(worktreePath, sourcePath), 'utf8');
|
|
366
457
|
if (!sensor.fresh) return;
|
|
458
|
+
// 網は受入の一点だけ(ADR 0145)。fresh indexの上でしか意味を持たないので、
|
|
459
|
+
// sensorが新pathを見ていない時は数えず、observation欠落として別理由で落とす。
|
|
460
|
+
observation.severed = await detectSeveredReferences({
|
|
461
|
+
worktreePath, files: rewritten.files, residualPath: sourcePath,
|
|
462
|
+
});
|
|
367
463
|
const post = buildPostTransformWitnessSet({
|
|
368
464
|
witnessSet, candidate, affectedTestsByPath: sensor.affectedByPath,
|
|
369
465
|
});
|
|
@@ -389,6 +485,7 @@ export async function applySeamConflict({
|
|
|
389
485
|
exportSurface: observation?.afterText === null || observation?.afterText === undefined
|
|
390
486
|
? { preserved: false, missing: [] }
|
|
391
487
|
: compareExportSurface({ before: beforeText, after: observation.afterText }),
|
|
488
|
+
severed: observation?.severed ?? null,
|
|
392
489
|
focusedTestsPassed: verifierFailure === null,
|
|
393
490
|
sensorFresh: observation?.sensor?.fresh === true,
|
|
394
491
|
conflictPairs: afterPairs === null ? { targetResolved: false }
|