@quolu/lattice 0.59.0 → 0.60.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.
@@ -17,6 +17,7 @@ import {
17
17
  export const TODO_EXTRACTION_SCHEMA = 'lattice.todo_extraction.v1';
18
18
  export const TODO_EXTRACTION_SCHEMA_V2 = 'lattice.todo_extraction.v2';
19
19
  export const TODO_EXTRACTION_SCHEMA_V3 = 'lattice.todo_extraction.v3';
20
+ export const TODO_EXTRACTION_SCHEMA_V4 = 'lattice.todo_extraction.v4';
20
21
 
21
22
  const V1_DISPOSITIONS = new Set([
22
23
  'register_pending',
@@ -95,17 +96,17 @@ function historicalStart(value) {
95
96
  }
96
97
 
97
98
  function extractionTask(value, schema) {
98
- const v2 = [TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema);
99
- const v3 = schema === TODO_EXTRACTION_SCHEMA_V3;
99
+ const v2 = [TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(schema);
100
+ const designMemo = [TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(schema);
100
101
  const keys = [
101
102
  'task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
102
103
  'completion', 'source', 'migration_context',
103
104
  ];
104
105
  if (v2) keys.push('start');
105
- if (v3) keys.push('design_memo');
106
+ if (designMemo) keys.push('design_memo');
106
107
  if (!exactRecord(value, keys) || !isTodoIdentifier(value.task_id) || !boundedText(value.title)
107
108
  || !isTodoIdentifier(value.lane) || (value.narrative_ref !== null && !isTodoRef(value.narrative_ref))
108
- || (v3 && !isTodoDesignMemo(value.design_memo))
109
+ || (designMemo && !isTodoDesignMemo(value.design_memo))
109
110
  || value.compile_binding !== null || !(v2 ? V2_DISPOSITIONS : V1_DISPOSITIONS).has(value.disposition)
110
111
  || !sourceLocation(value.source) || !migrationContext(value.migration_context)) return false;
111
112
  if (!v2) return value.disposition === 'register_done' ? completion(value.completion) : value.completion === null;
@@ -116,9 +117,23 @@ function extractionTask(value, schema) {
116
117
  return value.start === null && value.completion === null;
117
118
  }
118
119
 
119
- function validateEdges(value) {
120
+ function isCrossPlanEdge(edge) {
121
+ return edge?.from?.project_id !== edge?.to?.project_id
122
+ || edge?.from?.plan_key !== edge?.to?.plan_key;
123
+ }
124
+
125
+ function validateEdges(value, schema) {
120
126
  return Array.isArray(value) && value.length <= TODO_LIMITS.edgesPerPlan
121
- && value.every((edge) => exactRecord(edge, ['from', 'to']) && nodeRef(edge.from) && nodeRef(edge.to))
127
+ && value.every((edge) => {
128
+ const basic = exactRecord(edge, ['from', 'to']);
129
+ const refsValid = nodeRef(edge?.from) && nodeRef(edge?.to);
130
+ const crossPlan = refsValid && isCrossPlanEdge(edge);
131
+ const withReason = [TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(schema) && crossPlan
132
+ && exactRecord(edge, ['from', 'to', 'reason'])
133
+ && boundedText(edge.reason);
134
+ return (basic || withReason) && refsValid
135
+ && (!crossPlan || ![TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(schema) || withReason);
136
+ })
122
137
  && sortedStrictly(value, (edge) => `${refKey(edge.from)}\0${refKey(edge.to)}`);
123
138
  }
124
139
 
@@ -131,6 +146,23 @@ function validateJoins(value) {
131
146
  && sortedStrictly(value, (join) => join.id);
132
147
  }
133
148
 
149
+ /**
150
+ * taskを新規登録しない入力は、既存source planの1 taskから別planの1 taskへ張る
151
+ * 明示dependencyだけに限定する。top-level identityはsource planを指し、両端の
152
+ * topology digestで、古い観測のまま接続しない。
153
+ */
154
+ export function isTodoExtractionConnectionOnly(value) {
155
+ const edge = Array.isArray(value?.hard_dependencies) && value.hard_dependencies.length === 1
156
+ ? value.hard_dependencies[0] : null;
157
+ return value?.schema === TODO_EXTRACTION_SCHEMA_V4
158
+ && Array.isArray(value.tasks) && value.tasks.length === 0
159
+ && Array.isArray(value.joins) && value.joins.length === 0
160
+ && edge !== null && nodeRef(edge.from) && nodeRef(edge.to) && isCrossPlanEdge(edge)
161
+ && edge.from.project_id === value.project_id && edge.from.plan_key === value.plan_key
162
+ && isTodoDigest(edge.from.expected_topology_digest)
163
+ && isTodoDigest(edge.to.expected_topology_digest);
164
+ }
165
+
134
166
  const reject = (reason, path = '', detail = {}) => ({ valid: false, reason, path, ...detail });
135
167
 
136
168
  /** value自体がexactRecordの対象になれる素朴なobjectかどうか(配列・nullを除く)。 */
@@ -180,13 +212,15 @@ export function explainTodoExtraction(value) {
180
212
  try {
181
213
  if (!plainObject(value)) return reject('not_an_object', '');
182
214
  const schema = value.schema;
183
- if (![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema)) {
215
+ if (![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3,
216
+ TODO_EXTRACTION_SCHEMA_V4].includes(schema)) {
184
217
  return reject('schema_mismatch', '/schema', {
185
- expected: TODO_EXTRACTION_SCHEMA_V3, actual: typeof schema === 'string' ? schema : null,
218
+ expected: TODO_EXTRACTION_SCHEMA_V4, actual: typeof schema === 'string' ? schema : null,
186
219
  });
187
220
  }
188
- const v2 = [TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema);
189
- const v3 = schema === TODO_EXTRACTION_SCHEMA_V3;
221
+ const v2 = [TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(schema);
222
+ const designMemo = [TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(schema);
223
+ const v4 = schema === TODO_EXTRACTION_SCHEMA_V4;
190
224
  const topKeys = [
191
225
  'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
192
226
  'tasks', 'hard_dependencies', 'joins', 'extraction_digest',
@@ -198,8 +232,8 @@ export function explainTodoExtraction(value) {
198
232
  if (!isTodoIdentifier(value.plan_version)) return reject('invalid_identifier', '/plan_version');
199
233
  if (!actor(value.actor)) return reject('invalid_actor', '/actor');
200
234
  if (!isStrictTodoTimestamp(value.recorded_at)) return reject('invalid_timestamp', '/recorded_at');
201
- if (!Array.isArray(value.tasks) || value.tasks.length === 0
202
- || value.tasks.length > TODO_LIMITS.tasksPerPlan) {
235
+ if (!Array.isArray(value.tasks) || value.tasks.length > TODO_LIMITS.tasksPerPlan
236
+ || (value.tasks.length === 0 && !v4)) {
203
237
  return reject('bounded_collection_violation', '/tasks');
204
238
  }
205
239
  const taskKeys = v2
@@ -207,11 +241,11 @@ export function explainTodoExtraction(value) {
207
241
  'start', 'completion', 'source', 'migration_context']
208
242
  : ['task_id', 'title', 'lane', 'narrative_ref', 'compile_binding', 'disposition',
209
243
  'completion', 'source', 'migration_context'];
210
- if (v3) taskKeys.push('design_memo');
244
+ if (designMemo) taskKeys.push('design_memo');
211
245
  for (const [index, task] of value.tasks.entries()) {
212
246
  const taskKeyCheck = explainKeys(task, taskKeys, `/tasks/${index}`);
213
247
  if (!taskKeyCheck.valid) return taskKeyCheck;
214
- if (v3 && !isTodoDesignMemo(task.design_memo)) {
248
+ if (designMemo && !isTodoDesignMemo(task.design_memo)) {
215
249
  return reject('design_memo_required', `/tasks/${index}/design_memo`, {
216
250
  task_id: isTodoIdentifier(task.task_id) ? task.task_id : null,
217
251
  expected: 'non-empty Markdown or NO_PLAN',
@@ -241,7 +275,7 @@ export function explainTodoExtraction(value) {
241
275
  if (new Set(value.tasks.map(({ task_id: taskId }) => taskId)).size !== value.tasks.length) {
242
276
  return reject('duplicate_task_id', '/tasks');
243
277
  }
244
- if (!validateEdges(value.hard_dependencies)) return reject('hard_dependencies_invalid', '/hard_dependencies');
278
+ if (!validateEdges(value.hard_dependencies, schema)) return reject('hard_dependencies_invalid', '/hard_dependencies');
245
279
  if (!validateJoins(value.joins)) return reject('joins_invalid', '/joins');
246
280
  if (!isTodoDigest(value.extraction_digest)) return reject('invalid_digest', '/extraction_digest');
247
281
  const expectedDigest = todoSelfDigest(value, 'extraction_digest');
@@ -250,33 +284,44 @@ export function explainTodoExtraction(value) {
250
284
  expected: expectedDigest, actual: value.extraction_digest,
251
285
  });
252
286
  }
253
- const taskIds = new Set(value.tasks.map(({ task_id: taskId }) => taskId));
254
- const badParent = value.tasks.find((task) => task.source.parent_task_id === task.task_id
255
- || (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)));
256
- if (badParent !== undefined) {
257
- return reject('parent_task_id_unresolved',
258
- `/tasks/${value.tasks.indexOf(badParent)}/source/parent_task_id`);
259
- }
260
- const excluded = new Set(value.tasks
261
- .filter((task) => task.disposition.startsWith('exclude_'))
262
- .map(({ task_id }) => task_id));
263
- const excludedParent = value.tasks.find((task) => task.disposition.startsWith('register_')
264
- && task.source.parent_task_id !== null && excluded.has(task.source.parent_task_id));
265
- if (excludedParent !== undefined) {
266
- return reject('registered_parent_task_id_unresolved',
267
- `/tasks/${value.tasks.indexOf(excludedParent)}/source/parent_task_id`, {
268
- task_id: excludedParent.task_id,
269
- expected: 'task_id not excluded from the compiled plan',
270
- actual: excludedParent.source.parent_task_id,
271
- });
287
+ if (v4 && value.hard_dependencies.filter(isCrossPlanEdge).length > 1) {
288
+ return reject('cross_plan_dependency_limit_exceeded', '/hard_dependencies', { maximum: 1 });
272
289
  }
273
- const localRefViolation = firstUnregisteredLocalRef(value);
274
- if (localRefViolation !== null) {
275
- return reject('local_ref_unresolved', localRefViolation.path, {
276
- task_id: localRefViolation.task_id,
277
- expected: 'registered task_id', actual: localRefViolation.task_id,
290
+ const connectionOnly = isTodoExtractionConnectionOnly(value);
291
+ if (value.tasks.length === 0 && !connectionOnly) {
292
+ return reject('connection_only_shape_invalid', '/tasks', {
293
+ expected: 'one reasoned cross-plan dependency from the top-level source plan',
278
294
  });
279
295
  }
296
+ if (!connectionOnly) {
297
+ const taskIds = new Set(value.tasks.map(({ task_id: taskId }) => taskId));
298
+ const badParent = value.tasks.find((task) => task.source.parent_task_id === task.task_id
299
+ || (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)));
300
+ if (badParent !== undefined) {
301
+ return reject('parent_task_id_unresolved',
302
+ `/tasks/${value.tasks.indexOf(badParent)}/source/parent_task_id`);
303
+ }
304
+ const excluded = new Set(value.tasks
305
+ .filter((task) => task.disposition.startsWith('exclude_'))
306
+ .map(({ task_id }) => task_id));
307
+ const excludedParent = value.tasks.find((task) => task.disposition.startsWith('register_')
308
+ && task.source.parent_task_id !== null && excluded.has(task.source.parent_task_id));
309
+ if (excludedParent !== undefined) {
310
+ return reject('registered_parent_task_id_unresolved',
311
+ `/tasks/${value.tasks.indexOf(excludedParent)}/source/parent_task_id`, {
312
+ task_id: excludedParent.task_id,
313
+ expected: 'task_id not excluded from the compiled plan',
314
+ actual: excludedParent.source.parent_task_id,
315
+ });
316
+ }
317
+ const localRefViolation = firstUnregisteredLocalRef(value);
318
+ if (localRefViolation !== null) {
319
+ return reject('local_ref_unresolved', localRefViolation.path, {
320
+ task_id: localRefViolation.task_id,
321
+ expected: 'registered task_id', actual: localRefViolation.task_id,
322
+ });
323
+ }
324
+ }
280
325
  // ここまでの個別検査を全て通過したのに`validateTodoExtraction`がfalseを返す状況は、
281
326
  // このexplainがまだ言い当てられない違反があるということ。捏造せず未特定と申告する。
282
327
  return { valid: true };
@@ -320,19 +365,26 @@ export function validateTodoExtraction(value) {
320
365
  if (!exactRecord(value, [
321
366
  'schema', 'project_id', 'plan_key', 'plan_version', 'actor', 'recorded_at',
322
367
  'tasks', 'hard_dependencies', 'joins', 'extraction_digest',
323
- ]) || ![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3].includes(schema)
368
+ ]) || ![TODO_EXTRACTION_SCHEMA, TODO_EXTRACTION_SCHEMA_V2, TODO_EXTRACTION_SCHEMA_V3,
369
+ TODO_EXTRACTION_SCHEMA_V4].includes(schema)
324
370
  || !isTodoIdentifier(value.project_id)
325
371
  || !isTodoIdentifier(value.plan_key) || !isTodoIdentifier(value.plan_version)
326
372
  || !actor(value.actor) || !isStrictTodoTimestamp(value.recorded_at)
327
- || !Array.isArray(value.tasks) || value.tasks.length === 0
328
- || value.tasks.length > TODO_LIMITS.tasksPerPlan
373
+ || !Array.isArray(value.tasks) || value.tasks.length > TODO_LIMITS.tasksPerPlan
374
+ || (value.tasks.length === 0 && schema !== TODO_EXTRACTION_SCHEMA_V4)
329
375
  || !value.tasks.every((task) => extractionTask(task, schema))
330
376
  || !sortedStrictly(value.tasks, (task) => task.task_id)
331
377
  || new Set(value.tasks.map(({ task_id }) => task_id)).size !== value.tasks.length
332
- || !validateEdges(value.hard_dependencies) || !validateJoins(value.joins)
378
+ || !validateEdges(value.hard_dependencies, schema) || !validateJoins(value.joins)
333
379
  || !isTodoDigest(value.extraction_digest)
334
380
  || value.extraction_digest !== todoSelfDigest(value, 'extraction_digest')) return false;
335
381
 
382
+ if (schema === TODO_EXTRACTION_SCHEMA_V4 && value.hard_dependencies.filter(isCrossPlanEdge).length > 1) {
383
+ return false;
384
+ }
385
+ const connectionOnly = isTodoExtractionConnectionOnly(value);
386
+ if (value.tasks.length === 0 && !connectionOnly) return false;
387
+ if (connectionOnly) return true;
336
388
  const taskIds = new Set(value.tasks.map(({ task_id }) => task_id));
337
389
  if (value.tasks.some((task) => task.source.parent_task_id === task.task_id
338
390
  || (task.source.parent_task_id !== null && !taskIds.has(task.source.parent_task_id)))) return false;
@@ -381,9 +433,32 @@ export function compileTodoExtraction(value, repoRoot) {
381
433
  }
382
434
 
383
435
  const registered = value.tasks.filter(({ disposition }) => disposition.startsWith('register_'));
384
- if (registered.length === 0) {
436
+ const connectionOnly = isTodoExtractionConnectionOnly(value);
437
+ if (registered.length === 0 && !connectionOnly) {
385
438
  throw new TodoStoreError('MIGRATION_EMPTY', 'no_registered_tasks');
386
439
  }
440
+ const hardDependencies = value.hard_dependencies.map(({ from, to }) => ({ from, to }));
441
+ const crossPlanDependencies = [TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(value.schema)
442
+ ? value.hard_dependencies
443
+ .filter(isCrossPlanEdge)
444
+ .map(({ from, to, reason }) => ({ from, to, reason }))
445
+ : [];
446
+ if (connectionOnly) {
447
+ return {
448
+ repoRoot,
449
+ writer: createTodoStoreWriter({ caller: 'g4-migration' }),
450
+ connectionOnly: true,
451
+ connectionPlan: {
452
+ project_id: value.project_id, plan_key: value.plan_key, plan_version: value.plan_version,
453
+ },
454
+ crossPlanDependencies,
455
+ genesis: {
456
+ actor: value.actor,
457
+ recorded_at: value.recorded_at,
458
+ provenance: null,
459
+ },
460
+ };
461
+ }
387
462
  return {
388
463
  repoRoot,
389
464
  writer: createTodoStoreWriter({ caller: 'g4-migration' }),
@@ -392,7 +467,7 @@ export function compileTodoExtraction(value, repoRoot) {
392
467
  repositories: [{ repo_id: 'self', path: '.' }],
393
468
  },
394
469
  plan: {
395
- schema: value.schema === TODO_EXTRACTION_SCHEMA_V3
470
+ schema: [TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(value.schema)
396
471
  ? 'lattice.todo_plan.v6' : 'lattice.todo_plan.v2',
397
472
  project_id: value.project_id,
398
473
  plan_key: value.plan_key,
@@ -402,17 +477,18 @@ export function compileTodoExtraction(value, repoRoot) {
402
477
  task_id: task.task_id,
403
478
  title: task.title,
404
479
  lane: task.lane,
405
- ...(value.schema === TODO_EXTRACTION_SCHEMA_V3
480
+ ...([TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(value.schema)
406
481
  ? { design_memo: task.design_memo } : {}),
407
482
  narrative_ref: task.narrative_ref,
408
483
  narrative_anchor: null,
409
484
  compile_binding: null,
410
- ...(value.schema === TODO_EXTRACTION_SCHEMA_V3
485
+ ...([TODO_EXTRACTION_SCHEMA_V3, TODO_EXTRACTION_SCHEMA_V4].includes(value.schema)
411
486
  ? { parent_task_id: task.source.parent_task_id } : {}),
412
487
  })),
413
- hard_dependencies: value.hard_dependencies,
488
+ hard_dependencies: hardDependencies,
414
489
  joins: value.joins,
415
490
  },
491
+ crossPlanDependencies,
416
492
  narrativeAnchorSources: registered.map((task) => ({
417
493
  task_id: task.task_id,
418
494
  origin_plan_ref: task.source.origin_plan_ref,