@quolu/lattice 0.52.0 → 0.52.2
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/LICENSE +147 -147
- package/README.ja.md +355 -355
- package/README.md +258 -258
- package/bin/lattice-mcp.mjs +0 -0
- package/bin/lattice-scripted-adapter.mjs +0 -0
- package/bin/lattice-scripted-worker.mjs +0 -0
- package/bin/lattice-work-order-adapter.mjs +0 -0
- package/bin/lattice.mjs +0 -0
- package/docs/bridge-setup.md +132 -132
- package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
- package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
- package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
- package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
- package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
- package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
- package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
- package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
- package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
- package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
- package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
- package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
- package/package.json +103 -103
- package/sensor/LICENSE +21 -21
- package/sensor/NOTICE +19 -19
- package/sensor/dist/bin/lattice-sensor.js +9 -9
- package/sensor/dist/db/index.js +24 -24
- package/sensor/dist/db/migrations.js +41 -41
- package/sensor/dist/db/queries.js +164 -164
- package/sensor/dist/db/schema.sql +205 -205
- package/sensor/dist/directory.js +5 -5
- package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
- package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
- package/sensor/dist/mcp/liveness-watchdog.js +53 -53
- package/sensor/dist/mcp/server-instructions.js +95 -95
- package/sensor/package.json +56 -56
- 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-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/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1053
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +697 -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 -1502
- 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 -520
- package/src/runtime-cli.mjs +4588 -4584
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -600
- 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 -162
- 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 -477
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -290
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -834
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1188
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
- 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 -1167
- 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 +3141 -3126
- 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 -625
- 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 +4322 -4314
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
import {
|
|
2
|
-
lstat, readFile, realpath,
|
|
3
|
-
} from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { parseTree } from 'jsonc-parser';
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
digestTodoArtifact,
|
|
9
|
-
exactRecord,
|
|
10
|
-
isTodoIdentifier,
|
|
11
|
-
} from './todo-contracts.mjs';
|
|
12
|
-
import { TodoStoreError } from './todo-store.mjs';
|
|
13
|
-
|
|
14
|
-
export const TODO_GANTT_PRESENTATION_REF = '.lattice/todo/gantt-presentation.json';
|
|
15
|
-
export const TODO_GANTT_PRESENTATION_SCHEMA = 'lattice.todo_gantt_presentation.v1';
|
|
16
|
-
export const TODO_GANTT_PRESENTATION_MODEL_SCHEMA = 'lattice.todo_gantt_presentation_model.v1';
|
|
17
|
-
|
|
18
|
-
const MAX_PRESENTATION_BYTES = 64 * 1_024;
|
|
19
|
-
const MAX_PLANS = 256;
|
|
20
|
-
const MAX_LANES_PER_PLAN = 256;
|
|
21
|
-
const MAX_NAME_BYTES = 256;
|
|
22
|
-
const MAX_DESCRIPTION_BYTES = 2_048;
|
|
23
|
-
const MAX_TASK_NUMBER_DIGITS = 12;
|
|
24
|
-
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
25
|
-
const NUMERIC_SUFFIX = /(\d+)$/u;
|
|
26
|
-
|
|
27
|
-
function fail(reason, detail = {}) {
|
|
28
|
-
throw new TodoStoreError('PRESENTATION_INVALID', reason, undefined, detail);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function compareText(left, right) {
|
|
32
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function within(root, candidate) {
|
|
36
|
-
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function boundedText(value, maximumBytes) {
|
|
40
|
-
return typeof value === 'string' && value.length > 0 && value === value.trim()
|
|
41
|
-
&& !CONTROL.test(value) && Buffer.byteLength(value, 'utf8') <= maximumBytes;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function hasDuplicateJsonKey(node) {
|
|
45
|
-
if (node?.type === 'object') {
|
|
46
|
-
const keys = new Set();
|
|
47
|
-
for (const property of node.children ?? []) {
|
|
48
|
-
const [key, value] = property.children ?? [];
|
|
49
|
-
if (keys.has(key?.value) || hasDuplicateJsonKey(value)) return true;
|
|
50
|
-
keys.add(key?.value);
|
|
51
|
-
}
|
|
52
|
-
} else if (node?.type === 'array') {
|
|
53
|
-
return (node.children ?? []).some(hasDuplicateJsonKey);
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function validateDocument(value) {
|
|
59
|
-
if (!exactRecord(value, ['schema', 'project_id', 'plans'])
|
|
60
|
-
|| value.schema !== TODO_GANTT_PRESENTATION_SCHEMA
|
|
61
|
-
|| !isTodoIdentifier(value.project_id)
|
|
62
|
-
|| !Array.isArray(value.plans) || value.plans.length > MAX_PLANS) {
|
|
63
|
-
fail('presentation_schema_invalid');
|
|
64
|
-
}
|
|
65
|
-
const planKeys = new Set();
|
|
66
|
-
for (const plan of value.plans) {
|
|
67
|
-
if (!exactRecord(plan, ['plan_key', 'lanes']) || !isTodoIdentifier(plan.plan_key)
|
|
68
|
-
|| !Array.isArray(plan.lanes) || plan.lanes.length > MAX_LANES_PER_PLAN) {
|
|
69
|
-
fail('presentation_schema_invalid');
|
|
70
|
-
}
|
|
71
|
-
if (planKeys.has(plan.plan_key)) fail('presentation_plan_duplicate', { plan_key: plan.plan_key });
|
|
72
|
-
planKeys.add(plan.plan_key);
|
|
73
|
-
const lanes = new Set();
|
|
74
|
-
for (const lane of plan.lanes) {
|
|
75
|
-
if (!exactRecord(lane, ['lane', 'name', 'description']) || !isTodoIdentifier(lane.lane)
|
|
76
|
-
|| !boundedText(lane.name, MAX_NAME_BYTES)
|
|
77
|
-
|| !boundedText(lane.description, MAX_DESCRIPTION_BYTES)) {
|
|
78
|
-
fail('presentation_schema_invalid');
|
|
79
|
-
}
|
|
80
|
-
if (lanes.has(lane.lane)) {
|
|
81
|
-
fail('presentation_lane_duplicate', { plan_key: plan.plan_key, lane: lane.lane });
|
|
82
|
-
}
|
|
83
|
-
lanes.add(lane.lane);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return value;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function normalizedTaskNumber(taskId) {
|
|
90
|
-
const suffix = NUMERIC_SUFFIX.exec(taskId)?.[1] ?? null;
|
|
91
|
-
if (suffix === null || suffix.length > MAX_TASK_NUMBER_DIGITS) return null;
|
|
92
|
-
return { display_number: suffix, normalized_number: suffix.replace(/^0+(?=\d)/u, '') };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function taskNumberBindings(readModel) {
|
|
96
|
-
const candidatesByPlan = new Map();
|
|
97
|
-
for (const member of readModel.members) {
|
|
98
|
-
const plan = member?.plan;
|
|
99
|
-
for (const task of plan?.tasks ?? []) {
|
|
100
|
-
const candidate = normalizedTaskNumber(task.task_id);
|
|
101
|
-
if (candidate === null) continue;
|
|
102
|
-
const planKey = JSON.stringify([plan.project_id, plan.plan_key]);
|
|
103
|
-
if (!candidatesByPlan.has(planKey)) candidatesByPlan.set(planKey, new Map());
|
|
104
|
-
const byNumber = candidatesByPlan.get(planKey);
|
|
105
|
-
if (!byNumber.has(candidate.normalized_number)) byNumber.set(candidate.normalized_number, []);
|
|
106
|
-
byNumber.get(candidate.normalized_number).push({
|
|
107
|
-
project_id: plan.project_id,
|
|
108
|
-
plan_key: plan.plan_key,
|
|
109
|
-
task_id: task.task_id,
|
|
110
|
-
...candidate,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
const bindings = [];
|
|
115
|
-
for (const byNumber of candidatesByPlan.values()) {
|
|
116
|
-
for (const candidates of byNumber.values()) if (candidates.length === 1) bindings.push(candidates[0]);
|
|
117
|
-
}
|
|
118
|
-
const globalCounts = new Map();
|
|
119
|
-
for (const binding of bindings) {
|
|
120
|
-
globalCounts.set(binding.normalized_number, (globalCounts.get(binding.normalized_number) ?? 0) + 1);
|
|
121
|
-
}
|
|
122
|
-
return bindings.map((binding) => ({
|
|
123
|
-
...binding,
|
|
124
|
-
globally_unique: globalCounts.get(binding.normalized_number) === 1,
|
|
125
|
-
})).sort((left, right) => compareText(left.project_id, right.project_id)
|
|
126
|
-
|| compareText(left.plan_key, right.plan_key)
|
|
127
|
-
|| compareText(left.task_id, right.task_id));
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function projectTodoGanttPresentation(readModel, document) {
|
|
131
|
-
if (!exactRecord(readModel, ['schema', 'project_id', 'manifest', 'members', 'snapshot_stale'])
|
|
132
|
-
&& !(readModel?.schema === 'lattice.todo_store_read.v1'
|
|
133
|
-
&& isTodoIdentifier(readModel.project_id) && Array.isArray(readModel.members))) {
|
|
134
|
-
throw new TypeError('readModel must be lattice.todo_store_read.v1');
|
|
135
|
-
}
|
|
136
|
-
if (document !== null) validateDocument(document);
|
|
137
|
-
if (document !== null && document.project_id !== readModel.project_id) {
|
|
138
|
-
fail('presentation_project_mismatch', {
|
|
139
|
-
expected_project_id: readModel.project_id,
|
|
140
|
-
actual_project_id: document.project_id,
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const planLanes = new Map();
|
|
145
|
-
for (const member of readModel.members) {
|
|
146
|
-
const plan = member?.plan;
|
|
147
|
-
if (!isTodoIdentifier(plan?.project_id) || !isTodoIdentifier(plan?.plan_key)
|
|
148
|
-
|| !Array.isArray(plan?.tasks)) throw new TypeError('readModel member plan is invalid');
|
|
149
|
-
planLanes.set(plan.plan_key, new Set(plan.tasks.map(({ lane }) => lane)));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const lanes = [];
|
|
153
|
-
for (const plan of document?.plans ?? []) {
|
|
154
|
-
const knownLanes = planLanes.get(plan.plan_key);
|
|
155
|
-
if (knownLanes === undefined) fail('presentation_plan_unknown', { plan_key: plan.plan_key });
|
|
156
|
-
for (const lane of plan.lanes) {
|
|
157
|
-
if (!knownLanes.has(lane.lane)) {
|
|
158
|
-
fail('presentation_lane_unknown', { plan_key: plan.plan_key, lane: lane.lane });
|
|
159
|
-
}
|
|
160
|
-
lanes.push({ plan_key: plan.plan_key, ...lane });
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
lanes.sort((left, right) => compareText(left.plan_key, right.plan_key)
|
|
164
|
-
|| compareText(left.lane, right.lane));
|
|
165
|
-
|
|
166
|
-
const model = {
|
|
167
|
-
schema: TODO_GANTT_PRESENTATION_MODEL_SCHEMA,
|
|
168
|
-
project_id: readModel.project_id,
|
|
169
|
-
configured: document !== null,
|
|
170
|
-
lanes,
|
|
171
|
-
task_numbers: taskNumberBindings(readModel),
|
|
172
|
-
};
|
|
173
|
-
return { ...model, presentation_digest: digestTodoArtifact(model) };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function readDocument(repoRoot) {
|
|
177
|
-
const canonicalRoot = await realpath(repoRoot);
|
|
178
|
-
const absolute = path.resolve(canonicalRoot, TODO_GANTT_PRESENTATION_REF);
|
|
179
|
-
if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) fail('presentation_path_outside_repo');
|
|
180
|
-
let stats;
|
|
181
|
-
try {
|
|
182
|
-
stats = await lstat(absolute);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
if (error?.code === 'ENOENT') return null;
|
|
185
|
-
fail('presentation_unreadable');
|
|
186
|
-
}
|
|
187
|
-
if (stats.isSymbolicLink() || !stats.isFile() || stats.size > MAX_PRESENTATION_BYTES) {
|
|
188
|
-
fail(stats.size > MAX_PRESENTATION_BYTES
|
|
189
|
-
? 'presentation_size_limit_exceeded' : 'presentation_path_unsafe');
|
|
190
|
-
}
|
|
191
|
-
const resolved = await realpath(absolute);
|
|
192
|
-
if (resolved !== absolute || !within(canonicalRoot, resolved)) fail('presentation_path_unsafe');
|
|
193
|
-
const bytes = await readFile(resolved);
|
|
194
|
-
if (bytes.length > MAX_PRESENTATION_BYTES) fail('presentation_size_limit_exceeded');
|
|
195
|
-
let text;
|
|
196
|
-
try {
|
|
197
|
-
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
198
|
-
} catch {
|
|
199
|
-
fail('presentation_invalid_utf8');
|
|
200
|
-
}
|
|
201
|
-
const errors = [];
|
|
202
|
-
const tree = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
|
|
203
|
-
if (errors.length > 0 || tree === undefined) fail('presentation_json_parse_failed');
|
|
204
|
-
if (hasDuplicateJsonKey(tree)) fail('presentation_duplicate_key');
|
|
205
|
-
let document;
|
|
206
|
-
try {
|
|
207
|
-
document = JSON.parse(text);
|
|
208
|
-
} catch {
|
|
209
|
-
fail('presentation_json_parse_failed');
|
|
210
|
-
}
|
|
211
|
-
return validateDocument(document);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export async function loadTodoGanttPresentation({ repoRoot, readModel }) {
|
|
215
|
-
if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRoot is required');
|
|
216
|
-
return projectTodoGanttPresentation(readModel, await readDocument(repoRoot));
|
|
217
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
lstat, readFile, realpath,
|
|
3
|
+
} from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { parseTree } from 'jsonc-parser';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
digestTodoArtifact,
|
|
9
|
+
exactRecord,
|
|
10
|
+
isTodoIdentifier,
|
|
11
|
+
} from './todo-contracts.mjs';
|
|
12
|
+
import { TodoStoreError } from './todo-store.mjs';
|
|
13
|
+
|
|
14
|
+
export const TODO_GANTT_PRESENTATION_REF = '.lattice/todo/gantt-presentation.json';
|
|
15
|
+
export const TODO_GANTT_PRESENTATION_SCHEMA = 'lattice.todo_gantt_presentation.v1';
|
|
16
|
+
export const TODO_GANTT_PRESENTATION_MODEL_SCHEMA = 'lattice.todo_gantt_presentation_model.v1';
|
|
17
|
+
|
|
18
|
+
const MAX_PRESENTATION_BYTES = 64 * 1_024;
|
|
19
|
+
const MAX_PLANS = 256;
|
|
20
|
+
const MAX_LANES_PER_PLAN = 256;
|
|
21
|
+
const MAX_NAME_BYTES = 256;
|
|
22
|
+
const MAX_DESCRIPTION_BYTES = 2_048;
|
|
23
|
+
const MAX_TASK_NUMBER_DIGITS = 12;
|
|
24
|
+
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
25
|
+
const NUMERIC_SUFFIX = /(\d+)$/u;
|
|
26
|
+
|
|
27
|
+
function fail(reason, detail = {}) {
|
|
28
|
+
throw new TodoStoreError('PRESENTATION_INVALID', reason, undefined, detail);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function compareText(left, right) {
|
|
32
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function within(root, candidate) {
|
|
36
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function boundedText(value, maximumBytes) {
|
|
40
|
+
return typeof value === 'string' && value.length > 0 && value === value.trim()
|
|
41
|
+
&& !CONTROL.test(value) && Buffer.byteLength(value, 'utf8') <= maximumBytes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasDuplicateJsonKey(node) {
|
|
45
|
+
if (node?.type === 'object') {
|
|
46
|
+
const keys = new Set();
|
|
47
|
+
for (const property of node.children ?? []) {
|
|
48
|
+
const [key, value] = property.children ?? [];
|
|
49
|
+
if (keys.has(key?.value) || hasDuplicateJsonKey(value)) return true;
|
|
50
|
+
keys.add(key?.value);
|
|
51
|
+
}
|
|
52
|
+
} else if (node?.type === 'array') {
|
|
53
|
+
return (node.children ?? []).some(hasDuplicateJsonKey);
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validateDocument(value) {
|
|
59
|
+
if (!exactRecord(value, ['schema', 'project_id', 'plans'])
|
|
60
|
+
|| value.schema !== TODO_GANTT_PRESENTATION_SCHEMA
|
|
61
|
+
|| !isTodoIdentifier(value.project_id)
|
|
62
|
+
|| !Array.isArray(value.plans) || value.plans.length > MAX_PLANS) {
|
|
63
|
+
fail('presentation_schema_invalid');
|
|
64
|
+
}
|
|
65
|
+
const planKeys = new Set();
|
|
66
|
+
for (const plan of value.plans) {
|
|
67
|
+
if (!exactRecord(plan, ['plan_key', 'lanes']) || !isTodoIdentifier(plan.plan_key)
|
|
68
|
+
|| !Array.isArray(plan.lanes) || plan.lanes.length > MAX_LANES_PER_PLAN) {
|
|
69
|
+
fail('presentation_schema_invalid');
|
|
70
|
+
}
|
|
71
|
+
if (planKeys.has(plan.plan_key)) fail('presentation_plan_duplicate', { plan_key: plan.plan_key });
|
|
72
|
+
planKeys.add(plan.plan_key);
|
|
73
|
+
const lanes = new Set();
|
|
74
|
+
for (const lane of plan.lanes) {
|
|
75
|
+
if (!exactRecord(lane, ['lane', 'name', 'description']) || !isTodoIdentifier(lane.lane)
|
|
76
|
+
|| !boundedText(lane.name, MAX_NAME_BYTES)
|
|
77
|
+
|| !boundedText(lane.description, MAX_DESCRIPTION_BYTES)) {
|
|
78
|
+
fail('presentation_schema_invalid');
|
|
79
|
+
}
|
|
80
|
+
if (lanes.has(lane.lane)) {
|
|
81
|
+
fail('presentation_lane_duplicate', { plan_key: plan.plan_key, lane: lane.lane });
|
|
82
|
+
}
|
|
83
|
+
lanes.add(lane.lane);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizedTaskNumber(taskId) {
|
|
90
|
+
const suffix = NUMERIC_SUFFIX.exec(taskId)?.[1] ?? null;
|
|
91
|
+
if (suffix === null || suffix.length > MAX_TASK_NUMBER_DIGITS) return null;
|
|
92
|
+
return { display_number: suffix, normalized_number: suffix.replace(/^0+(?=\d)/u, '') };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function taskNumberBindings(readModel) {
|
|
96
|
+
const candidatesByPlan = new Map();
|
|
97
|
+
for (const member of readModel.members) {
|
|
98
|
+
const plan = member?.plan;
|
|
99
|
+
for (const task of plan?.tasks ?? []) {
|
|
100
|
+
const candidate = normalizedTaskNumber(task.task_id);
|
|
101
|
+
if (candidate === null) continue;
|
|
102
|
+
const planKey = JSON.stringify([plan.project_id, plan.plan_key]);
|
|
103
|
+
if (!candidatesByPlan.has(planKey)) candidatesByPlan.set(planKey, new Map());
|
|
104
|
+
const byNumber = candidatesByPlan.get(planKey);
|
|
105
|
+
if (!byNumber.has(candidate.normalized_number)) byNumber.set(candidate.normalized_number, []);
|
|
106
|
+
byNumber.get(candidate.normalized_number).push({
|
|
107
|
+
project_id: plan.project_id,
|
|
108
|
+
plan_key: plan.plan_key,
|
|
109
|
+
task_id: task.task_id,
|
|
110
|
+
...candidate,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const bindings = [];
|
|
115
|
+
for (const byNumber of candidatesByPlan.values()) {
|
|
116
|
+
for (const candidates of byNumber.values()) if (candidates.length === 1) bindings.push(candidates[0]);
|
|
117
|
+
}
|
|
118
|
+
const globalCounts = new Map();
|
|
119
|
+
for (const binding of bindings) {
|
|
120
|
+
globalCounts.set(binding.normalized_number, (globalCounts.get(binding.normalized_number) ?? 0) + 1);
|
|
121
|
+
}
|
|
122
|
+
return bindings.map((binding) => ({
|
|
123
|
+
...binding,
|
|
124
|
+
globally_unique: globalCounts.get(binding.normalized_number) === 1,
|
|
125
|
+
})).sort((left, right) => compareText(left.project_id, right.project_id)
|
|
126
|
+
|| compareText(left.plan_key, right.plan_key)
|
|
127
|
+
|| compareText(left.task_id, right.task_id));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function projectTodoGanttPresentation(readModel, document) {
|
|
131
|
+
if (!exactRecord(readModel, ['schema', 'project_id', 'manifest', 'members', 'snapshot_stale'])
|
|
132
|
+
&& !(readModel?.schema === 'lattice.todo_store_read.v1'
|
|
133
|
+
&& isTodoIdentifier(readModel.project_id) && Array.isArray(readModel.members))) {
|
|
134
|
+
throw new TypeError('readModel must be lattice.todo_store_read.v1');
|
|
135
|
+
}
|
|
136
|
+
if (document !== null) validateDocument(document);
|
|
137
|
+
if (document !== null && document.project_id !== readModel.project_id) {
|
|
138
|
+
fail('presentation_project_mismatch', {
|
|
139
|
+
expected_project_id: readModel.project_id,
|
|
140
|
+
actual_project_id: document.project_id,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const planLanes = new Map();
|
|
145
|
+
for (const member of readModel.members) {
|
|
146
|
+
const plan = member?.plan;
|
|
147
|
+
if (!isTodoIdentifier(plan?.project_id) || !isTodoIdentifier(plan?.plan_key)
|
|
148
|
+
|| !Array.isArray(plan?.tasks)) throw new TypeError('readModel member plan is invalid');
|
|
149
|
+
planLanes.set(plan.plan_key, new Set(plan.tasks.map(({ lane }) => lane)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const lanes = [];
|
|
153
|
+
for (const plan of document?.plans ?? []) {
|
|
154
|
+
const knownLanes = planLanes.get(plan.plan_key);
|
|
155
|
+
if (knownLanes === undefined) fail('presentation_plan_unknown', { plan_key: plan.plan_key });
|
|
156
|
+
for (const lane of plan.lanes) {
|
|
157
|
+
if (!knownLanes.has(lane.lane)) {
|
|
158
|
+
fail('presentation_lane_unknown', { plan_key: plan.plan_key, lane: lane.lane });
|
|
159
|
+
}
|
|
160
|
+
lanes.push({ plan_key: plan.plan_key, ...lane });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
lanes.sort((left, right) => compareText(left.plan_key, right.plan_key)
|
|
164
|
+
|| compareText(left.lane, right.lane));
|
|
165
|
+
|
|
166
|
+
const model = {
|
|
167
|
+
schema: TODO_GANTT_PRESENTATION_MODEL_SCHEMA,
|
|
168
|
+
project_id: readModel.project_id,
|
|
169
|
+
configured: document !== null,
|
|
170
|
+
lanes,
|
|
171
|
+
task_numbers: taskNumberBindings(readModel),
|
|
172
|
+
};
|
|
173
|
+
return { ...model, presentation_digest: digestTodoArtifact(model) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function readDocument(repoRoot) {
|
|
177
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
178
|
+
const absolute = path.resolve(canonicalRoot, TODO_GANTT_PRESENTATION_REF);
|
|
179
|
+
if (!within(canonicalRoot, absolute) || absolute === canonicalRoot) fail('presentation_path_outside_repo');
|
|
180
|
+
let stats;
|
|
181
|
+
try {
|
|
182
|
+
stats = await lstat(absolute);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error?.code === 'ENOENT') return null;
|
|
185
|
+
fail('presentation_unreadable');
|
|
186
|
+
}
|
|
187
|
+
if (stats.isSymbolicLink() || !stats.isFile() || stats.size > MAX_PRESENTATION_BYTES) {
|
|
188
|
+
fail(stats.size > MAX_PRESENTATION_BYTES
|
|
189
|
+
? 'presentation_size_limit_exceeded' : 'presentation_path_unsafe');
|
|
190
|
+
}
|
|
191
|
+
const resolved = await realpath(absolute);
|
|
192
|
+
if (resolved !== absolute || !within(canonicalRoot, resolved)) fail('presentation_path_unsafe');
|
|
193
|
+
const bytes = await readFile(resolved);
|
|
194
|
+
if (bytes.length > MAX_PRESENTATION_BYTES) fail('presentation_size_limit_exceeded');
|
|
195
|
+
let text;
|
|
196
|
+
try {
|
|
197
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
198
|
+
} catch {
|
|
199
|
+
fail('presentation_invalid_utf8');
|
|
200
|
+
}
|
|
201
|
+
const errors = [];
|
|
202
|
+
const tree = parseTree(text, errors, { allowTrailingComma: false, disallowComments: true });
|
|
203
|
+
if (errors.length > 0 || tree === undefined) fail('presentation_json_parse_failed');
|
|
204
|
+
if (hasDuplicateJsonKey(tree)) fail('presentation_duplicate_key');
|
|
205
|
+
let document;
|
|
206
|
+
try {
|
|
207
|
+
document = JSON.parse(text);
|
|
208
|
+
} catch {
|
|
209
|
+
fail('presentation_json_parse_failed');
|
|
210
|
+
}
|
|
211
|
+
return validateDocument(document);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function loadTodoGanttPresentation({ repoRoot, readModel }) {
|
|
215
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) throw new TypeError('repoRoot is required');
|
|
216
|
+
return projectTodoGanttPresentation(readModel, await readDocument(repoRoot));
|
|
217
|
+
}
|