@quolu/lattice 0.57.2 → 0.58.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.
@@ -0,0 +1,163 @@
1
+ import { todoSelfDigest } from './todo-contracts.mjs';
2
+ import {
3
+ readTodoStructureRealizationChain,
4
+ readTodoStructureSource,
5
+ } from './todo-store.mjs';
6
+ import {
7
+ projectTodoStructureEffective,
8
+ readTodoStructureFinalizationState,
9
+ readTodoStructureState,
10
+ } from './todo-structure-store.mjs';
11
+
12
+ export const TODO_STRUCTURE_PRESENTATION_SCHEMA = 'lattice.todo_structure_presentation.v1';
13
+
14
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
15
+
16
+ function nextActions({ coverage, planKey, findings }) {
17
+ const actions = [];
18
+ const add = (value) => { if (typeof value === 'string' && !actions.includes(value)) actions.push(value); };
19
+ findings.forEach(({ next_action: nextAction }) => add(nextAction));
20
+ if (coverage === 'superseded') {
21
+ add(`lattice todo structure input --plan ${planKey} --input <migrated-structure-set.json> --dry-run --json`);
22
+ } else if (['inconsistent', 'unknown'].includes(coverage)) {
23
+ add(`lattice todo structure --plan ${planKey} --json`);
24
+ } else if (coverage === 'stale') {
25
+ add(`lattice todo structure finalize --plan ${planKey} --json`);
26
+ } else {
27
+ add(`lattice todo structure --plan ${planKey} --json`);
28
+ }
29
+ return actions;
30
+ }
31
+
32
+ async function realizationsFor(repoRoot, source) {
33
+ const entries = [];
34
+ for (const task of source.tasks.filter(({ applicability }) => applicability === 'graph')) {
35
+ entries.push(...await readTodoStructureRealizationChain({
36
+ repoRoot, structureSet: source, taskId: task.task_id,
37
+ }));
38
+ }
39
+ return entries;
40
+ }
41
+
42
+ function taskPresentation(source, effective) {
43
+ const byTask = new Map(effective.tasks.map((task) => [task.task_id, task]));
44
+ return source.tasks.map((task) => {
45
+ if (task.applicability === 'excluded') return {
46
+ task_id: task.task_id, applicability: 'excluded', excluded_reason: task.excluded_reason,
47
+ form: 'excluded', planned_outcome: null, effective_outcome: null,
48
+ changed_fields: [], realization_digest: null, code_anchors: [],
49
+ };
50
+ const projected = byTask.get(task.task_id);
51
+ return {
52
+ task_id: task.task_id, applicability: 'graph', excluded_reason: null,
53
+ form: projected.form,
54
+ planned_outcome: task.planned.outcome,
55
+ effective_outcome: projected.effective.outcome,
56
+ changed_fields: projected.changed_fields,
57
+ realization_digest: projected.realization_digest,
58
+ code_anchors: projected.effective.code_anchors.map((anchor) => ({
59
+ anchor_id: anchor.anchor_id, effect: anchor.effect, path: anchor.path,
60
+ symbol: anchor.symbol, expected_at: anchor.expected_at,
61
+ })),
62
+ };
63
+ });
64
+ }
65
+
66
+ function provenancePresentation(artifact) {
67
+ if (artifact === null) return null;
68
+ return {
69
+ baseline_sha: artifact.git_provenance.baseline_sha,
70
+ current_head_sha: artifact.git_provenance.head_sha,
71
+ commits: artifact.git_provenance.changesets.map((changeset) => ({
72
+ commit_oid: changeset.commit_oid,
73
+ changes: changeset.changes.map((change) => ({
74
+ path: change.path, previous_path: change.previous_path,
75
+ change: change.change, file_kind: change.file_kind,
76
+ })),
77
+ })),
78
+ };
79
+ }
80
+
81
+ function unreadablePlan(member, error) {
82
+ return {
83
+ plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
84
+ coverage: 'unreadable', freshness: 'unreadable', enabled: false,
85
+ verdict: null, compiled_verdict: null, structure_set_digest: null,
86
+ artifact_digest: null, finalization: null, tasks: [], graph: { nodes: [], edges: [] },
87
+ provenance: null, finding_summary: null, findings: [],
88
+ unreadable_reason: `${error?.code ?? error?.constructor?.name ?? 'Error'}:${error?.detail?.reason ?? error?.message ?? 'unreadable'}`,
89
+ next_actions: [`lattice todo structure --plan ${member.plan.plan_key} --json`],
90
+ };
91
+ }
92
+
93
+ /** 保存済みartifactだけからGantt用の独立構造面を作る。sensorは起動しない。 */
94
+ export async function loadTodoStructurePresentation({ repoRoot, readModel } = {}) {
95
+ if (typeof repoRoot !== 'string' || readModel?.schema !== 'lattice.todo_store_read.v1'
96
+ || !Array.isArray(readModel.members)) {
97
+ throw new TypeError('repoRoot and lattice.todo_store_read.v1 are required');
98
+ }
99
+ const plans = [];
100
+ for (const member of readModel.members) {
101
+ let source;
102
+ try {
103
+ source = await readTodoStructureSource({ repoRoot, planKey: member.plan.plan_key });
104
+ } catch (error) {
105
+ plans.push(unreadablePlan(member, error));
106
+ continue;
107
+ }
108
+ if (source === null) continue;
109
+ try {
110
+ const state = await readTodoStructureState({
111
+ repoRoot, store: readModel, planKey: member.plan.plan_key,
112
+ });
113
+ const realizations = source.plan_version === member.plan.plan_version
114
+ ? await realizationsFor(repoRoot, source) : [];
115
+ const effective = projectTodoStructureEffective({ structureSet: source, realizations });
116
+ const finalization = await readTodoStructureFinalizationState({
117
+ repoRoot, store: readModel, planKey: member.plan.plan_key,
118
+ });
119
+ // finalize後は、着手前にactivateしたcompile artifactではなく、最終形態を
120
+ // 再compileして保存したfinalization artifactが表示正本になる。
121
+ const artifact = finalization.artifact ?? state.artifact;
122
+ const freshness = finalization.artifact === null ? state.status : finalization.status;
123
+ const compiledVerdict = artifact?.overlay?.verdict ?? null;
124
+ const unboundVerdict = state.status === 'missing'
125
+ && state.reason === 'activation_binding_missing'
126
+ && ['inconsistent', 'unknown'].includes(compiledVerdict)
127
+ ? compiledVerdict : null;
128
+ const coverage = unboundVerdict
129
+ ?? (freshness === 'fresh' ? compiledVerdict : freshness);
130
+ const findings = artifact?.overlay?.findings ?? [];
131
+ plans.push({
132
+ plan_key: member.plan.plan_key, plan_version: member.plan.plan_version,
133
+ coverage, freshness, enabled: state.binding_digest !== null,
134
+ verdict: unboundVerdict ?? (freshness === 'fresh' ? compiledVerdict : null),
135
+ compiled_verdict: compiledVerdict,
136
+ structure_set_digest: state.structure_set_digest,
137
+ artifact_digest: artifact?.artifact_digest ?? null,
138
+ finalization: {
139
+ required: finalization.required, status: finalization.status,
140
+ reason: finalization.reason, stale_reasons: finalization.stale_reasons,
141
+ },
142
+ tasks: taskPresentation(source, effective),
143
+ graph: artifact?.overlay?.graph ?? { nodes: [], edges: [] },
144
+ provenance: provenancePresentation(artifact),
145
+ finding_summary: artifact?.overlay?.finding_summary ?? null,
146
+ findings,
147
+ unreadable_reason: null,
148
+ next_actions: nextActions({ coverage, planKey: member.plan.plan_key, findings }),
149
+ });
150
+ } catch (error) {
151
+ plans.push(unreadablePlan(member, error));
152
+ }
153
+ }
154
+ plans.sort((left, right) => compareText(left.plan_key, right.plan_key));
155
+ const projection = {
156
+ schema: TODO_STRUCTURE_PRESENTATION_SCHEMA,
157
+ project_id: readModel.project_id,
158
+ plans,
159
+ projection_digest: '',
160
+ };
161
+ projection.projection_digest = todoSelfDigest(projection, 'projection_digest');
162
+ return projection;
163
+ }
@@ -0,0 +1,417 @@
1
+ import { digestTodoArtifact, todoSelfDigest } from './todo-contracts.mjs';
2
+ import { explainTodoStructureSet } from './todo-structure-contracts.mjs';
3
+ import { collectSensorEvidence, portableSensorOutcome } from './sensor-adapter.mjs';
4
+
5
+ export const TODO_STRUCTURE_SOURCE_PROJECTION_SCHEMA = 'lattice.todo_structure_source_projection.v1';
6
+ export const TODO_STRUCTURE_SOURCE_LIMITS = Object.freeze({
7
+ edgesPerDirection: 128,
8
+ sensorTraversalLimit: 200,
9
+ });
10
+
11
+ const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
12
+ const isPlain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
13
+
14
+ function assertStructureSet(structureSet) {
15
+ const result = explainTodoStructureSet(structureSet);
16
+ if (!result.valid) {
17
+ throw new TypeError(`structureSetがcontractを満たさない: ${result.reason} ${result.path}`);
18
+ }
19
+ }
20
+
21
+ function queryId(operation, target = '', exactPath = null) {
22
+ if (operation === 'status') return 'structure-status';
23
+ return `structure-${operation}-${digestTodoArtifact({ operation, target, exact_path: exactPath }).slice(0, 64)}`;
24
+ }
25
+
26
+ function queryFor(operation, target = undefined, exactPath = null) {
27
+ return operation === 'status'
28
+ ? { id: queryId(operation), operation }
29
+ : {
30
+ id: queryId(operation, target, exactPath), operation, target,
31
+ ...(['callers', 'callees'].includes(operation) ? { exact_path: exactPath } : {}),
32
+ };
33
+ }
34
+
35
+ function graphAnchors(structureSet, effectiveTransforms = null) {
36
+ return structureSet.tasks
37
+ .filter(({ applicability }) => applicability === 'graph')
38
+ .flatMap(({ task_id: taskId, planned }) => (
39
+ effectiveTransforms?.get(taskId) ?? planned
40
+ ).code_anchors.map((anchor) => ({
41
+ task_id: taskId,
42
+ ...anchor,
43
+ })));
44
+ }
45
+
46
+ /**
47
+ * structure anchorを既存LatticeSensorの公開queryだけへ決定的に変換する。
48
+ * parser、index、node ID、edge抽出はここでは所有しない。
49
+ */
50
+ export function buildTodoStructureSensorQuerySet(structureSet, { effectiveTransforms = null } = {}) {
51
+ assertStructureSet(structureSet);
52
+ const anchors = graphAnchors(structureSet, effectiveTransforms);
53
+ const paths = [...new Set(anchors.map(({ path }) => path))].sort(compareText);
54
+ const symbols = [...new Set(anchors
55
+ .map(({ symbol }) => symbol)
56
+ .filter((symbol) => symbol !== null))].sort(compareText);
57
+ const pathsBySymbol = new Map(symbols.map((symbol) => [symbol, [...new Set(anchors
58
+ .filter((anchor) => anchor.symbol === symbol)
59
+ .map(({ path: anchorPath }) => anchorPath))].sort(compareText)]));
60
+ return {
61
+ queries: [
62
+ queryFor('status'),
63
+ ...paths.map((target) => queryFor('affected', target)),
64
+ ...symbols.flatMap((target) => [
65
+ queryFor('query', target),
66
+ ...pathsBySymbol.get(target).flatMap((anchorPath) => [
67
+ queryFor('callers', target, anchorPath),
68
+ queryFor('callees', target, anchorPath),
69
+ ]),
70
+ ]),
71
+ ],
72
+ };
73
+ }
74
+
75
+ /** runtime-front-endと同じportable evidence identityを作る公開入口。 */
76
+ export function todoStructurePortableEvidenceDigest(query, outcome) {
77
+ if (!isPlain(query) || !isPlain(outcome)
78
+ || outcome.id !== query.id || outcome.operation !== query.operation
79
+ || typeof outcome.outcome !== 'string') {
80
+ throw new TypeError('queryとsensor outcomeが一致しない');
81
+ }
82
+ const portable = portableSensorOutcome(outcome);
83
+ // exact symbol候補のscoreは検索順位の実行時telemetryであり、構造projectionが読むのはnodeだけ。
84
+ // 浮動小数のscoreをcanonical artifact identityへ混ぜると、同じsymbolでも索引状況でdigestが揺れる。
85
+ const stripCandidateScores = (entries) => Array.isArray(entries)
86
+ ? entries.map((entry) => isPlain(entry) && isPlain(entry.node)
87
+ ? Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'score')) : entry)
88
+ : entries;
89
+ if (query.operation === 'query') portable.data = stripCandidateScores(portable.data);
90
+ if (['callers', 'callees'].includes(query.operation)
91
+ && Object.hasOwn(portable, 'resolution')) {
92
+ portable.resolution = stripCandidateScores(portable.resolution);
93
+ }
94
+ return digestTodoArtifact({
95
+ query_id: query.id,
96
+ operation: query.operation,
97
+ status: outcome.outcome,
98
+ portable,
99
+ });
100
+ }
101
+
102
+ function indexOutcomes(querySet, collected) {
103
+ if (!isPlain(collected) || !Array.isArray(collected.outcomes)
104
+ || collected.outcomes.length !== querySet.queries.length) {
105
+ throw new TypeError('collected sensor evidenceがquery setと一致しない');
106
+ }
107
+ const outcomes = new Map();
108
+ querySet.queries.forEach((query, index) => {
109
+ const outcome = collected.outcomes[index];
110
+ if (!isPlain(outcome) || outcome.id !== query.id || outcome.operation !== query.operation
111
+ || typeof outcome.outcome !== 'string' || outcomes.has(query.id)) {
112
+ throw new TypeError(`collected sensor outcomeがqueryと一致しない: ${query.id}`);
113
+ }
114
+ outcomes.set(query.id, outcome);
115
+ });
116
+ return outcomes;
117
+ }
118
+
119
+ function evidenceRef(query, outcome) {
120
+ return {
121
+ query_id: query.id,
122
+ portable_digest: todoStructurePortableEvidenceDigest(query, outcome),
123
+ };
124
+ }
125
+
126
+ function pathObservation(query, outcome, target) {
127
+ const result = Array.isArray(outcome.targets) && outcome.targets.length === 1
128
+ ? outcome.targets[0]
129
+ : null;
130
+ if (!isPlain(result) || result.target !== target) {
131
+ return { existence: 'unknown', reason: 'STRUCTURE_SENSOR_PATH_UNRESOLVED' };
132
+ }
133
+ if (result.path_state === 'absent') {
134
+ return { existence: 'absent', reason: null };
135
+ }
136
+ if (['ready', 'empty'].includes(result.outcome) && !Object.hasOwn(result, 'path_state')) {
137
+ return { existence: 'present', reason: null };
138
+ }
139
+ return { existence: 'unknown', reason: 'STRUCTURE_SENSOR_PATH_UNRESOLVED' };
140
+ }
141
+
142
+ function naturalNode(candidate) {
143
+ const node = candidate?.node;
144
+ if (!isPlain(node) || typeof node.name !== 'string' || node.name.length === 0
145
+ || typeof node.kind !== 'string' || node.kind.length === 0
146
+ || typeof node.filePath !== 'string' || node.filePath.length === 0) return null;
147
+ return {
148
+ kind: node.kind,
149
+ path: node.filePath,
150
+ name: node.name,
151
+ qualified_name: typeof node.qualifiedName === 'string' ? node.qualifiedName : null,
152
+ start_line: Number.isSafeInteger(node.startLine) ? node.startLine : null,
153
+ end_line: Number.isSafeInteger(node.endLine) ? node.endLine : null,
154
+ };
155
+ }
156
+
157
+ function sameNode(left, right) {
158
+ return left !== null && right !== null
159
+ && left.kind === right.kind && left.path === right.path && left.name === right.name
160
+ && left.qualified_name === right.qualified_name
161
+ && left.start_line === right.start_line && left.end_line === right.end_line;
162
+ }
163
+
164
+ function symbolObservation(outcome, anchor) {
165
+ if (outcome.outcome === 'symbol_absent') {
166
+ return { existence: 'absent', node: null, candidates: [], reason: null };
167
+ }
168
+ if (outcome.outcome !== 'ready' || !Array.isArray(outcome.data)) {
169
+ return { existence: 'unknown', node: null, candidates: [], reason: 'STRUCTURE_SENSOR_SYMBOL_UNRESOLVED' };
170
+ }
171
+ if (outcome.data.length > 1) {
172
+ const candidates = outcome.data.map(naturalNode);
173
+ if (candidates.some((candidate) => candidate === null)) {
174
+ return { existence: 'unknown', node: null, candidates: [], reason: 'STRUCTURE_SENSOR_SYMBOL_MALFORMED' };
175
+ }
176
+ return { existence: 'unknown', node: null, candidates, reason: 'STRUCTURE_CODE_ANCHOR_AMBIGUOUS' };
177
+ }
178
+ if (outcome.data.length === 0) {
179
+ return { existence: 'absent', node: null, candidates: [], reason: null };
180
+ }
181
+ const node = naturalNode(outcome.data[0]);
182
+ if (node === null) {
183
+ return { existence: 'unknown', node: null, candidates: [], reason: 'STRUCTURE_SENSOR_SYMBOL_MALFORMED' };
184
+ }
185
+ if (node.path !== anchor.path) {
186
+ return { existence: 'absent', node: null, candidates: [], reason: null };
187
+ }
188
+ return { existence: 'present', node, candidates: [node], reason: null };
189
+ }
190
+
191
+ function naturalEdge(entry) {
192
+ if (!isPlain(entry) || typeof entry.name !== 'string' || entry.name.length === 0
193
+ || typeof entry.kind !== 'string' || entry.kind.length === 0
194
+ || typeof entry.filePath !== 'string' || entry.filePath.length === 0
195
+ || typeof entry.edgeKind !== 'string' || entry.edgeKind.length === 0
196
+ || typeof entry.valueRef !== 'boolean' || typeof entry.valueWrite !== 'boolean') return null;
197
+ return {
198
+ kind: entry.kind,
199
+ path: entry.filePath,
200
+ name: entry.name,
201
+ start_line: Number.isSafeInteger(entry.startLine) ? entry.startLine : null,
202
+ edge_kind: entry.edgeKind,
203
+ value_ref: entry.valueRef,
204
+ value_write: entry.valueWrite,
205
+ };
206
+ }
207
+
208
+ function edgeObservation(outcome, expectedNode, direction, expectedPath) {
209
+ const key = direction === 'incoming' ? 'callers' : 'callees';
210
+ if (outcome.outcome !== 'ready' || !Array.isArray(outcome.resolution)
211
+ || outcome.resolution.length !== 1 || !sameNode(naturalNode(outcome.resolution[0]), expectedNode)
212
+ || !isPlain(outcome.data) || outcome.data.exactPath !== expectedPath
213
+ || outcome.data.exactResolution !== 'ready' || !Array.isArray(outcome.data[key])) {
214
+ return {
215
+ state: 'unknown', edges: [], omitted_count: 0, source_limit_reached: false,
216
+ reason: 'STRUCTURE_SENSOR_EDGE_UNRESOLVED',
217
+ };
218
+ }
219
+ const edges = outcome.data[key].map(naturalEdge);
220
+ if (edges.some((entry) => entry === null)) {
221
+ return {
222
+ state: 'unknown', edges: [], omitted_count: 0, source_limit_reached: false,
223
+ reason: 'STRUCTURE_SENSOR_EDGE_MALFORMED',
224
+ };
225
+ }
226
+ edges.sort((left, right) => compareText(JSON.stringify(left), JSON.stringify(right)));
227
+ const projected = edges.slice(0, TODO_STRUCTURE_SOURCE_LIMITS.edgesPerDirection);
228
+ return {
229
+ state: 'complete',
230
+ edges: projected,
231
+ omitted_count: edges.length - projected.length,
232
+ source_limit_reached: edges.length >= TODO_STRUCTURE_SOURCE_LIMITS.sensorTraversalLimit,
233
+ reason: null,
234
+ };
235
+ }
236
+
237
+ function effectVerdict(anchor, existence) {
238
+ if (anchor.expected_at !== 'current') {
239
+ return { verdict: 'unknown', reason: 'STRUCTURE_CODE_ANCHOR_TIME_DEFERRED' };
240
+ }
241
+ if (existence === 'unknown') {
242
+ return { verdict: 'unknown', reason: 'STRUCTURE_CODE_ANCHOR_UNRESOLVED' };
243
+ }
244
+ if (anchor.effect === 'create') {
245
+ return existence === 'absent'
246
+ ? { verdict: 'consistent', reason: null }
247
+ : { verdict: 'inconsistent', reason: 'STRUCTURE_CREATE_ALREADY_EXISTS' };
248
+ }
249
+ return existence === 'present'
250
+ ? { verdict: 'consistent', reason: null }
251
+ : { verdict: 'inconsistent', reason: 'STRUCTURE_CODE_ANCHOR_ABSENT' };
252
+ }
253
+
254
+ function unknownAnchor(anchor, evidence, reason) {
255
+ return {
256
+ task_id: anchor.task_id,
257
+ anchor_id: anchor.anchor_id,
258
+ effect: anchor.effect,
259
+ expected_at: anchor.expected_at,
260
+ path: anchor.path,
261
+ symbol: anchor.symbol,
262
+ verdict: 'unknown',
263
+ reason,
264
+ existence: 'unknown',
265
+ coverage: 'unknown',
266
+ node: null,
267
+ candidates: [],
268
+ edges: {
269
+ state: anchor.symbol === null ? 'not_applicable' : 'unknown',
270
+ incoming: [], outgoing: [], incoming_omitted: 0, outgoing_omitted: 0,
271
+ incoming_source_limit_reached: false, outgoing_source_limit_reached: false,
272
+ },
273
+ evidence,
274
+ };
275
+ }
276
+
277
+ function projectAnchor({ anchor, querySet, outcomes, statusReady }) {
278
+ const pathQuery = queryFor('affected', anchor.path);
279
+ const pathOutcome = outcomes.get(pathQuery.id);
280
+ const evidence = {
281
+ status: evidenceRef(querySet.queries[0], outcomes.get(querySet.queries[0].id)),
282
+ path: evidenceRef(pathQuery, pathOutcome),
283
+ symbol: null,
284
+ callers: null,
285
+ callees: null,
286
+ };
287
+ if (!statusReady) return unknownAnchor(anchor, evidence, 'STRUCTURE_SENSOR_NOT_READY');
288
+
289
+ const pathResult = pathObservation(pathQuery, pathOutcome, anchor.path);
290
+ if (anchor.symbol === null) {
291
+ const decision = effectVerdict(anchor, pathResult.existence);
292
+ return {
293
+ ...unknownAnchor(anchor, evidence, decision.reason),
294
+ verdict: decision.verdict,
295
+ reason: pathResult.reason ?? decision.reason,
296
+ existence: pathResult.existence,
297
+ coverage: pathResult.existence === 'unknown'
298
+ ? 'unknown'
299
+ : pathResult.existence === 'absent'
300
+ ? anchor.effect === 'create' && decision.verdict === 'consistent'
301
+ ? 'expected_absence' : 'observed_absence'
302
+ : 'path_only',
303
+ };
304
+ }
305
+
306
+ const symbolQuery = queryFor('query', anchor.symbol);
307
+ const callersQuery = queryFor('callers', anchor.symbol, anchor.path);
308
+ const calleesQuery = queryFor('callees', anchor.symbol, anchor.path);
309
+ const symbolOutcome = outcomes.get(symbolQuery.id);
310
+ const callersOutcome = outcomes.get(callersQuery.id);
311
+ const calleesOutcome = outcomes.get(calleesQuery.id);
312
+ evidence.symbol = evidenceRef(symbolQuery, symbolOutcome);
313
+ evidence.callers = evidenceRef(callersQuery, callersOutcome);
314
+ evidence.callees = evidenceRef(calleesQuery, calleesOutcome);
315
+ const symbolResult = symbolObservation(symbolOutcome, anchor);
316
+ if (pathResult.existence === 'absent' && symbolResult.existence === 'present') {
317
+ return unknownAnchor(anchor, evidence, 'STRUCTURE_SENSOR_EVIDENCE_CONFLICT');
318
+ }
319
+ if (symbolResult.reason !== null) return {
320
+ ...unknownAnchor(anchor, evidence, symbolResult.reason),
321
+ candidates: symbolResult.candidates,
322
+ };
323
+
324
+ const decision = effectVerdict(anchor, symbolResult.existence);
325
+ if (symbolResult.existence !== 'present') {
326
+ return {
327
+ ...unknownAnchor(anchor, evidence, decision.reason),
328
+ verdict: decision.verdict,
329
+ reason: decision.reason,
330
+ existence: symbolResult.existence,
331
+ coverage: symbolResult.existence === 'absent'
332
+ ? anchor.effect === 'create' && decision.verdict === 'consistent'
333
+ ? 'expected_absence' : 'observed_absence'
334
+ : 'unknown',
335
+ };
336
+ }
337
+ const incoming = edgeObservation(callersOutcome, symbolResult.node, 'incoming', anchor.path);
338
+ const outgoing = edgeObservation(calleesOutcome, symbolResult.node, 'outgoing', anchor.path);
339
+ const edgeUnknown = incoming.state === 'unknown' || outgoing.state === 'unknown';
340
+ return {
341
+ task_id: anchor.task_id,
342
+ anchor_id: anchor.anchor_id,
343
+ effect: anchor.effect,
344
+ expected_at: anchor.expected_at,
345
+ path: anchor.path,
346
+ symbol: anchor.symbol,
347
+ verdict: edgeUnknown && decision.verdict === 'consistent' ? 'unknown' : decision.verdict,
348
+ reason: edgeUnknown && decision.verdict === 'consistent'
349
+ ? incoming.reason ?? outgoing.reason : decision.reason,
350
+ existence: 'present',
351
+ coverage: 'exact_symbol',
352
+ node: symbolResult.node,
353
+ candidates: symbolResult.candidates,
354
+ edges: {
355
+ state: edgeUnknown ? 'unknown' : 'complete',
356
+ incoming: incoming.edges,
357
+ outgoing: outgoing.edges,
358
+ incoming_omitted: incoming.omitted_count,
359
+ outgoing_omitted: outgoing.omitted_count,
360
+ incoming_source_limit_reached: incoming.source_limit_reached,
361
+ outgoing_source_limit_reached: outgoing.source_limit_reached,
362
+ },
363
+ evidence,
364
+ };
365
+ }
366
+
367
+ /** 収集済みsensor outcomeから、anchor到達範囲だけのbounded projectionを作る。 */
368
+ export function projectTodoStructureSourceEvidence({
369
+ structureSet, collected, effectiveTransforms = null,
370
+ } = {}) {
371
+ assertStructureSet(structureSet);
372
+ const querySet = buildTodoStructureSensorQuerySet(structureSet, { effectiveTransforms });
373
+ const outcomes = indexOutcomes(querySet, collected);
374
+ const statusQuery = querySet.queries[0];
375
+ const statusOutcome = outcomes.get(statusQuery.id);
376
+ const anchors = graphAnchors(structureSet, effectiveTransforms).map((anchor) => projectAnchor({
377
+ anchor, querySet, outcomes, statusReady: statusOutcome.outcome === 'ready',
378
+ }));
379
+ const projection = {
380
+ schema: TODO_STRUCTURE_SOURCE_PROJECTION_SCHEMA,
381
+ structure_set_digest: structureSet.structure_set_digest,
382
+ sensor_status: {
383
+ outcome: statusOutcome.outcome,
384
+ evidence: evidenceRef(statusQuery, statusOutcome),
385
+ },
386
+ anchors,
387
+ summary: {
388
+ graph_tasks: structureSet.tasks.filter(({ applicability }) => applicability === 'graph').length,
389
+ excluded_tasks: structureSet.tasks.filter(({ applicability }) => applicability === 'excluded').length,
390
+ projected_anchors: anchors.length,
391
+ omitted_anchors: 0,
392
+ incoming_edges_omitted: anchors.reduce((sum, anchor) => sum + anchor.edges.incoming_omitted, 0),
393
+ outgoing_edges_omitted: anchors.reduce((sum, anchor) => sum + anchor.edges.outgoing_omitted, 0),
394
+ },
395
+ projection_digest: '',
396
+ };
397
+ projection.projection_digest = todoSelfDigest(projection, 'projection_digest');
398
+ return projection;
399
+ }
400
+
401
+ /** 実sensor収集とpure projectionを同じ既存adapter経由で行う。 */
402
+ export async function collectTodoStructureSourceEvidence({
403
+ cwd, structureSet, effectiveTransforms = null,
404
+ execute = undefined, inspectAffectedPath = undefined,
405
+ } = {}) {
406
+ const querySet = buildTodoStructureSensorQuerySet(structureSet, { effectiveTransforms });
407
+ const collected = await collectSensorEvidence({
408
+ cwd,
409
+ querySet,
410
+ ...(execute === undefined ? {} : { execute }),
411
+ ...(inspectAffectedPath === undefined ? {} : { inspectAffectedPath }),
412
+ });
413
+ return {
414
+ query_set: querySet,
415
+ projection: projectTodoStructureSourceEvidence({ structureSet, collected, effectiveTransforms }),
416
+ };
417
+ }