@quolu/lattice 0.17.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@ import {
20
20
  seamProposalGuidanceCode,
21
21
  } from './todo-independence-guidance.mjs';
22
22
 
23
- export const SEAM_PROPOSAL_SCHEMA = 'lattice.seam_proposal.v1';
23
+ export const SEAM_PROPOSAL_SCHEMA = 'lattice.seam_proposal.v2';
24
24
  export const SEAM_PROPOSAL_PROJECTION_SCHEMA = 'lattice.seam_proposal_projection.v1';
25
25
  export const SEAM_PROPOSAL_VERDICTS = Object.freeze([
26
26
  'seam_candidate',
@@ -142,10 +142,18 @@ function surfaceEntry(value, taskIds, { proposed }) {
142
142
  : value.owner_task_ids.length === 1;
143
143
  }
144
144
 
145
+ /**
146
+ * `candidate_paths`は、同名symbolが複数fileに居た時だけ埋まる(ADR 0134)。
147
+ *
148
+ * 単数の`resolved_path`と併存させるのは、両者が別の事実だからである。前者は「一意に決まった」、
149
+ * 後者は「決まらず、候補はこれだけあった」。候補を持つ受け皿が無かった頃、後者は`unknown`へ
150
+ * 潰れ、宣言の`within`で絞れる情報が記録に残らなかった。一意に決まった時に候補を並べない
151
+ * (空配列に限る)のは、決まった事実と決まらなかった事実を同じ形にしないためである。
152
+ */
145
153
  function queryEntry(value) {
146
154
  return exactRecord(value, [
147
155
  'query_id', 'operation', 'target', 'outcome', 'resolved_name', 'resolved_path',
148
- 'result_digest',
156
+ 'candidate_paths', 'result_digest',
149
157
  ])
150
158
  && isTodoIdentifier(value.query_id)
151
159
  && isTodoIdentifier(value.operation)
@@ -153,6 +161,12 @@ function queryEntry(value) {
153
161
  && isTodoIdentifier(value.outcome)
154
162
  && (value.resolved_name === null || boundedText(value.resolved_name))
155
163
  && (value.resolved_path === null || repoRelativePath(value.resolved_path))
164
+ && boundedList(value.candidate_paths, repoRelativePath)
165
+ && strictlySorted(value.candidate_paths)
166
+ && (value.outcome === 'ambiguous'
167
+ ? value.candidate_paths.length >= 2 && value.resolved_path === null
168
+ && boundedText(value.resolved_name)
169
+ : value.candidate_paths.length === 0)
156
170
  && isTodoDigest(value.result_digest);
157
171
  }
158
172
 
@@ -284,7 +298,7 @@ function decisionEntry(value) {
284
298
  }
285
299
 
286
300
  /**
287
- * Validate the immutable public `lattice.seam_proposal.v1` artifact.
301
+ * Validate the immutable public `lattice.seam_proposal.v2` artifact.
288
302
  *
289
303
  * This validates the artifact's closed runtime shape and canonical relations. Binding the
290
304
  * recorded conflicts back to the referenced independence bytes is a consumer responsibility.
@@ -249,12 +249,24 @@ function exactSymbolResolution(outcome, target, operation) {
249
249
  return { outcome: 'unknown' };
250
250
  }
251
251
  const paths = [...new Set(exact.map(({ node }) => node.filePath))].sort(compareText);
252
- if (paths.length !== 1) return { outcome: 'unknown' };
253
- return { outcome: 'resolved', resolved_name: target, resolved_path: paths[0] };
252
+ if (paths.length === 1) {
253
+ return { outcome: 'resolved', resolved_name: target, resolved_path: paths[0] };
254
+ }
255
+ // 同名が複数fileにある。`query`は「その名前はどこに居るか」を問う操作なので、候補を
256
+ // 持ったまま返す——宣言の`within`が指す資源で絞れば一意に決まる場合がある(ADR 0134)。
257
+ // graph操作(callers/callees/impact)は逆に、展開の起点が一意でなければ意味を持たない。
258
+ // 同じ曖昧さでも問いが違うので、そちらはunknownのまま潰す。
259
+ if (operation === 'query') {
260
+ return { outcome: 'ambiguous', resolved_name: target, candidate_paths: paths };
261
+ }
262
+ return { outcome: 'unknown' };
254
263
  }
255
264
 
256
265
  function symbolResolutionForEvidence(local, canonical) {
257
- if (canonical.outcome === 'unknown') return { outcome: 'unknown' };
266
+ // 起点が一意でない名前についてのgraph観測は、どのsymbolについての観測か確定しない。
267
+ if (canonical.outcome === 'unknown' || canonical.outcome === 'ambiguous') {
268
+ return { outcome: 'unknown' };
269
+ }
258
270
  if (canonical.outcome === 'absent') {
259
271
  return local.outcome === 'absent' ? local : { outcome: 'unknown' };
260
272
  }
@@ -294,6 +306,7 @@ function evidenceQuery({ query, outcome, resolution }) {
294
306
  outcome: resolution.outcome,
295
307
  resolved_name: resolution.resolved_name ?? null,
296
308
  resolved_path: resolution.resolved_path ?? null,
309
+ candidate_paths: resolution.candidate_paths ?? [],
297
310
  result_digest: digestArtifact(portableSensorOutcome(outcome)),
298
311
  };
299
312
  }
@@ -472,7 +485,7 @@ async function collectCalleeClosure({
472
485
  /**
473
486
  * Collect through the bundled sensor adapter. The normalized evidence remains the only
474
487
  * contract-shaped artifact; raw outcomes are returned on a separate, in-memory channel for
475
- * structural cut enumeration and must not be embedded in lattice.seam_proposal.v1.
488
+ * structural cut enumeration and must not be embedded in lattice.seam_proposal.v2.
476
489
  */
477
490
  export async function collectSeamProposalEvidenceBundle({
478
491
  cwd,
@@ -774,13 +774,42 @@ function pathContains(resourcePath, symbolPath) {
774
774
  : symbolPath === resourcePath;
775
775
  }
776
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
+
777
802
  /**
778
803
  * Resolve declared concern anchors against fresh sensor evidence.
779
804
  *
780
- * A declaration only becomes a binding anchor when the sensor resolves the exact name to exactly
781
- * one path and that path lies inside the declared resource. Fuzzy resolution to a neighbouring
782
- * symbol, an absent name, or a symbol living outside the contested resource yields a typed
783
- * unknown instead — a wrong declaration must never widen what the binder believes it knows.
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.
784
813
  *
785
814
  * Two ToDos claiming the same symbol is a contradiction in the declarations themselves, not a cut
786
815
  * to be discovered: the anchor is dropped from both and reported, so neither side can be bound by
@@ -808,7 +837,7 @@ export function resolveConcernAnchors({ manualWitness, taskIds, evidence } = {})
808
837
  continue;
809
838
  }
810
839
  for (const symbol of entry.symbols) {
811
- const symbolPath = resolvedSymbolPath(queries, symbol);
840
+ const symbolPath = resolveConcernSymbolPath(queries, symbol, resourcePath);
812
841
  if (symbolPath === null) {
813
842
  unknowns.push({ kind: 'concern_anchor_unresolved', ref: `${taskId}:${symbol}` });
814
843
  continue;
@@ -1894,7 +1923,7 @@ function conflictComponents(independenceArtifact) {
1894
1923
  }
1895
1924
 
1896
1925
  /**
1897
- * Build the immutable lattice.seam_proposal.v1 artifact from one complete independence record.
1926
+ * Build the immutable lattice.seam_proposal.v2 artifact from one complete independence record.
1898
1927
  * Sensor collection stays outside this producer; callers pass the original witness evidence and
1899
1928
  * the seam-specific normalized/raw evidence collected for the same clean HEAD.
1900
1929
  */
package/src/todo-cli.mjs CHANGED
@@ -461,6 +461,7 @@ async function startAdvisory({ repoRoot, store, projection, planKey, taskId }) {
461
461
  contractSuperseded: isTodoIndependenceLegacyMarker(artifact),
462
462
  conflictWithActive: conflictsWithActive[0]?.severability ?? null,
463
463
  conflictBetweenReady: readyConflict?.severability ?? null,
464
+ verdictsAbsent: selfUnknowns.some(({ kind }) => kind === 'plan_verdicts_absent'),
464
465
  }),
465
466
  };
466
467
  }
@@ -836,6 +837,8 @@ async function independence({ repoRoot, requestedPlanKey }) {
836
837
  .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'record_stale')),
837
838
  conflictWithActive: projected.frontier.conflicts_with_active[0]?.severability ?? null,
838
839
  conflictBetweenReady: projected.frontier.serialize_pairs[0]?.severability ?? null,
840
+ verdictsAbsent: projected.frontier.unknown
841
+ .some(({ unknowns }) => unknowns.some(({ kind }) => kind === 'plan_verdicts_absent')),
839
842
  }),
840
843
  frontier: projected.frontier,
841
844
  result_digest: '',
@@ -17,6 +17,7 @@ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
17
17
  'independence_stale_for_task',
18
18
  'independence_conflict_with_active',
19
19
  'independence_conflict_between_ready',
20
+ 'independence_verdicts_absent',
20
21
  'independence_verified',
21
22
  ]);
22
23
 
@@ -90,6 +91,10 @@ const CATALOG = Object.freeze({
90
91
  message: '他のready工程と同じ資源を書く記録がある。同時に着手すると衝突する。',
91
92
  next_action: 'serialize_or_split_boundary',
92
93
  }),
94
+ independence_verdicts_absent: Object.freeze({
95
+ message: '判定が途中で止まっており、記録はどの組についてもverdictを持たない。自分にunknownが無いことは、他と干渉しない証拠にならない。',
96
+ next_action: 'resolve_unknowns_then_recompile',
97
+ }),
93
98
  independence_verified: Object.freeze({
94
99
  message: '記録時点の宣言境界では、他のready工程と干渉しない。',
95
100
  next_action: 'none',
@@ -164,7 +169,7 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
164
169
  */
165
170
  export function selectIndependenceGuidance({
166
171
  coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
167
- contractSuperseded = false, readyCount = null,
172
+ contractSuperseded = false, readyCount = null, verdictsAbsent = false,
168
173
  }) {
169
174
  // 着手候補が無いなら述べる対象が無い。ここを通さないと、readyが空のとき
170
175
  // 「未検査taskが1件も無い」が空虚に真になり、記録が古くても検証済みへ倒れる。
@@ -186,6 +191,9 @@ export function selectIndependenceGuidance({
186
191
  if (coverage === 'superseded') return todoIndependenceGuidance('independence_superseded');
187
192
  if (!taskDeclared) return todoIndependenceGuidance('independence_task_undeclared');
188
193
  if (taskStale) return todoIndependenceGuidance('independence_stale_for_task');
194
+ // 記録は新しく、この工程自身にも問題が無い。それでもverdictが1つも無いなら、
195
+ // 述べられるのは「干渉しない」ではなく「まだ何も判定していない」である。
196
+ if (verdictsAbsent) return todoIndependenceGuidance('independence_verdicts_absent');
189
197
  return todoIndependenceGuidance('independence_verified');
190
198
  }
191
199
 
@@ -386,6 +386,18 @@ export function projectIndependenceFrontier({
386
386
  for (const entry of artifact.unknowns) {
387
387
  if (ready.includes(entry.task_id)) noteUnknown(entry.task_id, entry.kind, entry.ref);
388
388
  }
389
+ // 記録全体がverdictを持たない場合、どのpairについても判定は存在しない。
390
+ //
391
+ // compileがBOUNDARY_UNKNOWNで止まると、front endはpairwise verdictを1つも返さないので
392
+ // artifact.conflictsは空になる。この空をそのまま読むと、自分にunknownが無いreadyどうしが
393
+ // 「ぶつかる記録が無い=独立」として並列グループへ入る。実際には**誰についても判定していない**。
394
+ // 新規fileを1つ宣言したToDoが混ざるだけで、無関係なToDoの実competitionが消えて
395
+ // 検証済み並列に見える——不在を証拠に読み替える、最も危険な向きの誤りである。
396
+ if (artifact.outcome !== 'compiled') {
397
+ for (const taskId of ready) {
398
+ if (covered.has(taskId)) noteUnknown(taskId, 'plan_verdicts_absent', artifact.outcome);
399
+ }
400
+ }
389
401
  // planが進んだ記録はtask単位に救えない(topology自体が別物)。
390
402
  // HEADだけが進んだ場合は、宣言境界に触れたtaskだけを落とす。
391
403
  if (coverage === 'superseded') {