barry-cache 0.2.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 +201 -0
- package/README.md +343 -0
- package/dist/cli.js +3863 -0
- package/package.json +28 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3863 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// src/core/context.ts
|
|
6
|
+
import { appendFile, mkdir as mkdir2 } from "node:fs/promises";
|
|
7
|
+
import { basename, join as join3 } from "node:path";
|
|
8
|
+
|
|
9
|
+
// src/core/fs.ts
|
|
10
|
+
import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join, relative } from "node:path";
|
|
12
|
+
async function exists(path) {
|
|
13
|
+
try {
|
|
14
|
+
await stat(path);
|
|
15
|
+
return true;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error.code === "ENOENT")
|
|
18
|
+
return false;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readText(path) {
|
|
23
|
+
return await readFile(path, "utf8");
|
|
24
|
+
}
|
|
25
|
+
async function readTextIfExists(path) {
|
|
26
|
+
return await exists(path) ? await readText(path) : "";
|
|
27
|
+
}
|
|
28
|
+
async function writeText(path, content) {
|
|
29
|
+
await mkdir(dirname(path), { recursive: true });
|
|
30
|
+
await writeFile(path, content);
|
|
31
|
+
}
|
|
32
|
+
async function writeIfChanged(path, content, dryRun = false) {
|
|
33
|
+
const currentExists = await exists(path);
|
|
34
|
+
if (currentExists && await readText(path) === content)
|
|
35
|
+
return "skipped";
|
|
36
|
+
if (!dryRun)
|
|
37
|
+
await writeText(path, content);
|
|
38
|
+
return currentExists ? "updated" : "written";
|
|
39
|
+
}
|
|
40
|
+
async function listDirs(path) {
|
|
41
|
+
if (!await exists(path))
|
|
42
|
+
return [];
|
|
43
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
44
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
45
|
+
}
|
|
46
|
+
function rel(repo, path) {
|
|
47
|
+
return relative(repo, path).replaceAll("\\", "/");
|
|
48
|
+
}
|
|
49
|
+
function repoPath(repo, ...parts) {
|
|
50
|
+
return join(repo, ...parts);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/core/validate.ts
|
|
54
|
+
import { join as join2 } from "node:path";
|
|
55
|
+
var requiredFiles = [
|
|
56
|
+
"docs/context/INDEX.md",
|
|
57
|
+
"docs/context/LOG.md",
|
|
58
|
+
"docs/context/MAINTENANCE.md",
|
|
59
|
+
"docs/context/schema/fact.schema.json"
|
|
60
|
+
];
|
|
61
|
+
async function validateProject({ repo }) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
const warnings = [];
|
|
64
|
+
for (const file of requiredFiles) {
|
|
65
|
+
if (!await exists(repoPath(repo, file))) {
|
|
66
|
+
errors.push({ file, message: "required context file is missing" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const featureRoot = repoPath(repo, "docs/context/features");
|
|
70
|
+
const features = await listDirs(featureRoot);
|
|
71
|
+
for (const slug of features) {
|
|
72
|
+
const factsPath = join2(featureRoot, slug, "FACTS.jsonl");
|
|
73
|
+
if (!await exists(factsPath)) {
|
|
74
|
+
warnings.push({ file: rel(repo, factsPath), message: "feature pack has no FACTS.jsonl" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const rows = (await readTextIfExists(factsPath)).split(/\r?\n/);
|
|
78
|
+
rows.forEach((row, index) => {
|
|
79
|
+
if (row.trim().length === 0)
|
|
80
|
+
return;
|
|
81
|
+
const line = index + 1;
|
|
82
|
+
try {
|
|
83
|
+
const value = JSON.parse(row);
|
|
84
|
+
const message = validateFact(value);
|
|
85
|
+
if (message)
|
|
86
|
+
errors.push({ file: rel(repo, factsPath), line, message });
|
|
87
|
+
} catch {
|
|
88
|
+
errors.push({ file: rel(repo, factsPath), line, message: "invalid JSON" });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
93
|
+
}
|
|
94
|
+
function validateFact(value) {
|
|
95
|
+
if (typeof value !== "object" || value === null)
|
|
96
|
+
return "fact must be an object";
|
|
97
|
+
const fact = value;
|
|
98
|
+
const required = ["id", "subject", "predicate", "object", "src", "status", "kind", "updated_at"];
|
|
99
|
+
for (const key of required) {
|
|
100
|
+
if (fact[key] === undefined)
|
|
101
|
+
return `missing required field: ${key}`;
|
|
102
|
+
}
|
|
103
|
+
for (const key of ["id", "subject", "predicate", "object", "status", "kind", "updated_at"]) {
|
|
104
|
+
if (typeof fact[key] !== "string" || fact[key].length === 0)
|
|
105
|
+
return `invalid field: ${key}`;
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(fact.src) || fact.src.length === 0 || fact.src.some((item) => typeof item !== "string")) {
|
|
108
|
+
return "invalid field: src";
|
|
109
|
+
}
|
|
110
|
+
if (fact.tags !== undefined && (!Array.isArray(fact.tags) || fact.tags.some((item) => typeof item !== "string"))) {
|
|
111
|
+
return "invalid field: tags";
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/core/context.ts
|
|
117
|
+
async function routeTask({ repo, task }) {
|
|
118
|
+
const features = await readFeaturePacks(repo);
|
|
119
|
+
const taskTokens = tokens(task);
|
|
120
|
+
const routes = features.map((feature) => scoreFeature(feature, taskTokens)).filter((route) => route.score > 0).sort((a, b) => b.score - a.score || a.slug.localeCompare(b.slug));
|
|
121
|
+
return { task, routes };
|
|
122
|
+
}
|
|
123
|
+
async function searchContext({ repo, query }) {
|
|
124
|
+
const features = await readFeaturePacks(repo);
|
|
125
|
+
const queryTokens = tokens(query);
|
|
126
|
+
const results = [];
|
|
127
|
+
for (const feature of features) {
|
|
128
|
+
const featureText = `${feature.slug} ${feature.readme}`;
|
|
129
|
+
const featureScore = scoreText(featureText, queryTokens);
|
|
130
|
+
if (featureScore > 0) {
|
|
131
|
+
results.push({
|
|
132
|
+
type: "feature",
|
|
133
|
+
id: feature.slug,
|
|
134
|
+
route: feature.slug,
|
|
135
|
+
score: featureScore,
|
|
136
|
+
text: firstLine(feature.readme) || feature.slug,
|
|
137
|
+
source: rel(repo, feature.dir)
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
for (const fact of feature.facts) {
|
|
141
|
+
const factText = factToText(fact);
|
|
142
|
+
const score = scoreText(factText, queryTokens);
|
|
143
|
+
if (score > 0) {
|
|
144
|
+
results.push({
|
|
145
|
+
type: "fact",
|
|
146
|
+
id: fact.id,
|
|
147
|
+
route: feature.slug,
|
|
148
|
+
score,
|
|
149
|
+
text: factText,
|
|
150
|
+
source: `${rel(repo, join3(feature.dir, "FACTS.jsonl"))}#${fact.id}`
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
results.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
156
|
+
return { query, results };
|
|
157
|
+
}
|
|
158
|
+
async function loadContext({ repo, route }) {
|
|
159
|
+
const features = await readFeaturePacks(repo);
|
|
160
|
+
const feature = features.find((item) => item.slug === route) ?? null;
|
|
161
|
+
if (!feature)
|
|
162
|
+
return { feature: null, facts: [], sources: [] };
|
|
163
|
+
return {
|
|
164
|
+
feature,
|
|
165
|
+
facts: feature.facts,
|
|
166
|
+
sources: [
|
|
167
|
+
rel(repo, join3(feature.dir, "README.md")),
|
|
168
|
+
rel(repo, join3(feature.dir, "IDMAP.md")),
|
|
169
|
+
rel(repo, join3(feature.dir, "KG.adj")),
|
|
170
|
+
rel(repo, join3(feature.dir, "FACTS.jsonl"))
|
|
171
|
+
]
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function resumeProject({ repo, task }) {
|
|
175
|
+
const context = await routeTask({ repo, task });
|
|
176
|
+
const selected = context.routes.slice(0, 3).map((route) => route.slug);
|
|
177
|
+
const firstAction = selected.length > 0 ? `load ${selected.join(", ")} context packs` : "load docs/context/INDEX.md and identify the smallest relevant context pack";
|
|
178
|
+
return {
|
|
179
|
+
task,
|
|
180
|
+
context,
|
|
181
|
+
execution_contract: {
|
|
182
|
+
task_goal: task,
|
|
183
|
+
first_action: firstAction,
|
|
184
|
+
edit_scope: selected.map((slug) => `docs/context/features/${slug}/**`),
|
|
185
|
+
validation_commands: ["barry-cache validate"],
|
|
186
|
+
contract_strength: "soft"
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function finalizeProject(options) {
|
|
191
|
+
const dir = repoPath(options.repo, ".context-state/handoffs");
|
|
192
|
+
await mkdir2(dir, { recursive: true });
|
|
193
|
+
const path = join3(dir, "handoffs.jsonl");
|
|
194
|
+
const record = {
|
|
195
|
+
id: `handoff-${new Date().toISOString()}`,
|
|
196
|
+
updated_at: new Date().toISOString(),
|
|
197
|
+
status: options.status,
|
|
198
|
+
summary: options.summary,
|
|
199
|
+
files: options.files ?? [],
|
|
200
|
+
tests: options.tests ?? []
|
|
201
|
+
};
|
|
202
|
+
await appendFile(path, `${JSON.stringify(record)}
|
|
203
|
+
`);
|
|
204
|
+
return { saved: true, path: rel(options.repo, path), summary: options.summary };
|
|
205
|
+
}
|
|
206
|
+
async function readFeaturePacks(repo) {
|
|
207
|
+
const root = repoPath(repo, "docs/context/features");
|
|
208
|
+
const slugs = await listDirs(root);
|
|
209
|
+
const features = [];
|
|
210
|
+
for (const slug of slugs) {
|
|
211
|
+
const dir = join3(root, slug);
|
|
212
|
+
features.push({
|
|
213
|
+
slug,
|
|
214
|
+
dir,
|
|
215
|
+
readme: await readTextIfExists(join3(dir, "README.md")),
|
|
216
|
+
idmap: await readTextIfExists(join3(dir, "IDMAP.md")),
|
|
217
|
+
graph: await readTextIfExists(join3(dir, "KG.adj")),
|
|
218
|
+
facts: await readFacts(join3(dir, "FACTS.jsonl"))
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return features;
|
|
222
|
+
}
|
|
223
|
+
async function readFacts(path) {
|
|
224
|
+
const rows = (await readTextIfExists(path)).split(/\r?\n/);
|
|
225
|
+
const facts = [];
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
if (row.trim().length === 0)
|
|
228
|
+
continue;
|
|
229
|
+
const parsed = JSON.parse(row);
|
|
230
|
+
if (validateFact(parsed) === null)
|
|
231
|
+
facts.push(parsed);
|
|
232
|
+
}
|
|
233
|
+
return facts;
|
|
234
|
+
}
|
|
235
|
+
function scoreFeature(feature, taskTokens) {
|
|
236
|
+
const text = [
|
|
237
|
+
feature.slug,
|
|
238
|
+
basename(feature.dir),
|
|
239
|
+
feature.readme,
|
|
240
|
+
feature.idmap,
|
|
241
|
+
feature.graph,
|
|
242
|
+
...feature.facts.map(factToText)
|
|
243
|
+
].join(" ");
|
|
244
|
+
const score = scoreText(text, taskTokens);
|
|
245
|
+
return {
|
|
246
|
+
slug: feature.slug,
|
|
247
|
+
score,
|
|
248
|
+
reason: score > 0 ? `matched ${score} task token${score === 1 ? "" : "s"}` : "no match"
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function scoreText(text, queryTokens) {
|
|
252
|
+
const haystack = text.toLowerCase();
|
|
253
|
+
return queryTokens.reduce((score, token) => score + (haystack.includes(token) ? 1 : 0), 0);
|
|
254
|
+
}
|
|
255
|
+
function tokens(input) {
|
|
256
|
+
return Array.from(new Set(input.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 3)));
|
|
257
|
+
}
|
|
258
|
+
function factToText(fact) {
|
|
259
|
+
return [
|
|
260
|
+
fact.id,
|
|
261
|
+
fact.subject,
|
|
262
|
+
fact.predicate,
|
|
263
|
+
fact.object,
|
|
264
|
+
fact.status,
|
|
265
|
+
fact.kind,
|
|
266
|
+
...fact.tags ?? []
|
|
267
|
+
].join(" ");
|
|
268
|
+
}
|
|
269
|
+
function firstLine(value) {
|
|
270
|
+
return value.split(/\r?\n/).find((line) => line.trim().length > 0)?.replace(/^#\s*/, "") ?? "";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/core/import-pulpcut.ts
|
|
274
|
+
import { join as join4 } from "node:path";
|
|
275
|
+
async function importPulpcutKb(options) {
|
|
276
|
+
const dryRun = options.dryRun ?? false;
|
|
277
|
+
const result = {
|
|
278
|
+
source: options.from,
|
|
279
|
+
source_type: "pulpcut-kb",
|
|
280
|
+
dryRun,
|
|
281
|
+
imported: 0,
|
|
282
|
+
features: [],
|
|
283
|
+
written: [],
|
|
284
|
+
updated: [],
|
|
285
|
+
skipped: [],
|
|
286
|
+
warnings: []
|
|
287
|
+
};
|
|
288
|
+
const sourceDocs = repoPath(options.from, "docs");
|
|
289
|
+
const kbIndex = await readTextIfExists(join4(sourceDocs, "KB_INDEX.md"));
|
|
290
|
+
if (kbIndex.trim().length === 0) {
|
|
291
|
+
throw new Error(`PulpCut KB index not found at ${join4(sourceDocs, "KB_INDEX.md")}`);
|
|
292
|
+
}
|
|
293
|
+
const routes = parseKbIndex(kbIndex);
|
|
294
|
+
const slugs = await listDirs(sourceDocs);
|
|
295
|
+
for (const slug of slugs) {
|
|
296
|
+
const sourceDir = join4(sourceDocs, slug);
|
|
297
|
+
const rawFacts = await readTextIfExists(join4(sourceDir, "FACTS.jsonl"));
|
|
298
|
+
if (rawFacts.trim().length === 0)
|
|
299
|
+
continue;
|
|
300
|
+
const idmap = await readTextIfExists(join4(sourceDir, "IDMAP.md"));
|
|
301
|
+
const graph = await readTextIfExists(join4(sourceDir, "KG.adj"));
|
|
302
|
+
if (idmap.trim().length === 0)
|
|
303
|
+
result.warnings.push(`${slug}: missing IDMAP.md`);
|
|
304
|
+
if (graph.trim().length === 0)
|
|
305
|
+
result.warnings.push(`${slug}: missing KG.adj`);
|
|
306
|
+
const facts = parsePulpcutFacts(rawFacts, slug, result.warnings);
|
|
307
|
+
const route = routes.get(slug);
|
|
308
|
+
const targetPrefix = `docs/context/features/${slug}`;
|
|
309
|
+
await writePlanned(options.repo, result, `${targetPrefix}/README.md`, featureReadme(slug, route, idmap), dryRun);
|
|
310
|
+
await writePlanned(options.repo, result, `${targetPrefix}/IDMAP.md`, normalizeText(idmap), dryRun);
|
|
311
|
+
await writePlanned(options.repo, result, `${targetPrefix}/KG.adj`, normalizeText(graph), dryRun);
|
|
312
|
+
await writePlanned(options.repo, result, `${targetPrefix}/FACTS.jsonl`, facts.map((fact) => JSON.stringify(fact)).join(`
|
|
313
|
+
`) + `
|
|
314
|
+
`, dryRun);
|
|
315
|
+
result.imported++;
|
|
316
|
+
result.features.push(slug);
|
|
317
|
+
}
|
|
318
|
+
if (result.imported === 0) {
|
|
319
|
+
result.warnings.push("No PulpCut KB feature folders with FACTS.jsonl were found.");
|
|
320
|
+
}
|
|
321
|
+
result.features.sort();
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
function parsePulpcutFacts(input, slug, warnings) {
|
|
325
|
+
const facts = [];
|
|
326
|
+
const seen = new Set;
|
|
327
|
+
input.split(/\r?\n/).forEach((line, index) => {
|
|
328
|
+
if (line.trim().length === 0)
|
|
329
|
+
return;
|
|
330
|
+
let parsed;
|
|
331
|
+
try {
|
|
332
|
+
parsed = JSON.parse(line);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
335
|
+
warnings.push(`${slug}/FACTS.jsonl:${index + 1} invalid JSON: ${message}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const id = stringValue(parsed.id);
|
|
339
|
+
const subject = stringValue(parsed.s);
|
|
340
|
+
const predicate = stringValue(parsed.p);
|
|
341
|
+
const object = objectValue(parsed.o);
|
|
342
|
+
const src = arrayOfStrings(parsed.src);
|
|
343
|
+
if (!id || !subject || !predicate || !object || src.length === 0) {
|
|
344
|
+
warnings.push(`${slug}/FACTS.jsonl:${index + 1} skipped row with missing id/s/p/o/src`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (seen.has(id))
|
|
348
|
+
warnings.push(`${slug}/FACTS.jsonl:${index + 1} duplicate fact id ${id}`);
|
|
349
|
+
seen.add(id);
|
|
350
|
+
const fact = {
|
|
351
|
+
id,
|
|
352
|
+
subject,
|
|
353
|
+
predicate,
|
|
354
|
+
object,
|
|
355
|
+
src,
|
|
356
|
+
status: "active",
|
|
357
|
+
kind: inferKind(predicate, object),
|
|
358
|
+
confidence: "high",
|
|
359
|
+
updated_at: new Date().toISOString()
|
|
360
|
+
};
|
|
361
|
+
const supersedes = stringOrStringArray(parsed.corrects);
|
|
362
|
+
if (supersedes)
|
|
363
|
+
fact.supersedes = supersedes;
|
|
364
|
+
const tags = stringValue(parsed.fact) ? ["pulpcut-import", "fact-note"] : ["pulpcut-import"];
|
|
365
|
+
fact.tags = tags;
|
|
366
|
+
facts.push(fact);
|
|
367
|
+
});
|
|
368
|
+
return facts;
|
|
369
|
+
}
|
|
370
|
+
function inferKind(predicate, object) {
|
|
371
|
+
const text = `${predicate} ${object}`.toLowerCase();
|
|
372
|
+
if (text.includes("test") || text.includes("verifies") || text.includes("coverage"))
|
|
373
|
+
return "test";
|
|
374
|
+
if (text.includes("risk"))
|
|
375
|
+
return "risk";
|
|
376
|
+
if (text.includes("missing") || text.includes("unknown") || text.includes("question"))
|
|
377
|
+
return "open-question";
|
|
378
|
+
if (text.includes("decision"))
|
|
379
|
+
return "decision";
|
|
380
|
+
if (text.includes("rule") || text.includes("requires") || text.includes("must") || text.includes("only when"))
|
|
381
|
+
return "constraint";
|
|
382
|
+
return "implemented";
|
|
383
|
+
}
|
|
384
|
+
function parseKbIndex(input) {
|
|
385
|
+
const routes = new Map;
|
|
386
|
+
let current = null;
|
|
387
|
+
for (const line of input.split(/\r?\n/)) {
|
|
388
|
+
const routeMatch = line.match(/^-\s+`([^`]+)`\s*$/);
|
|
389
|
+
if (routeMatch?.[1]) {
|
|
390
|
+
current = routeMatch[1];
|
|
391
|
+
routes.set(current, { useFor: "", add: "" });
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (!current)
|
|
395
|
+
continue;
|
|
396
|
+
const route = routes.get(current);
|
|
397
|
+
if (!route)
|
|
398
|
+
continue;
|
|
399
|
+
const useFor = line.match(/^\s+-\s+Use for:\s*(.+)$/);
|
|
400
|
+
if (useFor?.[1])
|
|
401
|
+
route.useFor = useFor[1].trim();
|
|
402
|
+
const add = line.match(/^\s+-\s+Add:\s*(.+)$/);
|
|
403
|
+
if (add?.[1])
|
|
404
|
+
route.add = add[1].trim();
|
|
405
|
+
}
|
|
406
|
+
return routes;
|
|
407
|
+
}
|
|
408
|
+
function featureReadme(slug, route, idmap) {
|
|
409
|
+
const scope = extractScope(idmap);
|
|
410
|
+
const sections = [
|
|
411
|
+
`# ${titleFromSlug(slug)}`,
|
|
412
|
+
"",
|
|
413
|
+
"Imported from a PulpCut KB feature folder.",
|
|
414
|
+
""
|
|
415
|
+
];
|
|
416
|
+
if (route?.useFor) {
|
|
417
|
+
sections.push("## Route", "", route.useFor, "");
|
|
418
|
+
}
|
|
419
|
+
if (route?.add) {
|
|
420
|
+
sections.push("## Secondary Context", "", route.add, "");
|
|
421
|
+
}
|
|
422
|
+
if (scope) {
|
|
423
|
+
sections.push("## Source Scope", "", scope, "");
|
|
424
|
+
}
|
|
425
|
+
return `${sections.join(`
|
|
426
|
+
`).trimEnd()}
|
|
427
|
+
`;
|
|
428
|
+
}
|
|
429
|
+
function extractScope(idmap) {
|
|
430
|
+
const lines = idmap.split(/\r?\n/);
|
|
431
|
+
const scope = [];
|
|
432
|
+
let inScope = false;
|
|
433
|
+
for (const line of lines) {
|
|
434
|
+
if (line.startsWith("## ")) {
|
|
435
|
+
if (inScope)
|
|
436
|
+
break;
|
|
437
|
+
inScope = /^##\s+Scope\b/.test(line);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (inScope && line.trim().length > 0)
|
|
441
|
+
scope.push(line.replace(/^\s*-\s*/, ""));
|
|
442
|
+
}
|
|
443
|
+
return scope.join(`
|
|
444
|
+
`);
|
|
445
|
+
}
|
|
446
|
+
async function writePlanned(repo, result, path, content, dryRun) {
|
|
447
|
+
const status = await writeIfChanged(repoPath(repo, path), content, dryRun);
|
|
448
|
+
if (status === "written")
|
|
449
|
+
result.written.push(path);
|
|
450
|
+
if (status === "updated")
|
|
451
|
+
result.updated.push(path);
|
|
452
|
+
if (status === "skipped")
|
|
453
|
+
result.skipped.push(path);
|
|
454
|
+
}
|
|
455
|
+
function normalizeText(input) {
|
|
456
|
+
return input.trimEnd().length === 0 ? "" : `${input.trimEnd()}
|
|
457
|
+
`;
|
|
458
|
+
}
|
|
459
|
+
function titleFromSlug(slug) {
|
|
460
|
+
return slug.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
461
|
+
}
|
|
462
|
+
function stringValue(value) {
|
|
463
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
464
|
+
}
|
|
465
|
+
function objectValue(value) {
|
|
466
|
+
if (typeof value === "string")
|
|
467
|
+
return value.length > 0 ? value : undefined;
|
|
468
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
469
|
+
return String(value);
|
|
470
|
+
if (value && typeof value === "object")
|
|
471
|
+
return JSON.stringify(value);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
function arrayOfStrings(value) {
|
|
475
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : [];
|
|
476
|
+
}
|
|
477
|
+
function stringOrStringArray(value) {
|
|
478
|
+
if (typeof value === "string" && value.length > 0)
|
|
479
|
+
return value;
|
|
480
|
+
const values = arrayOfStrings(value);
|
|
481
|
+
return values.length > 0 ? values : undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/core/init.ts
|
|
485
|
+
import { mkdir as mkdir3 } from "node:fs/promises";
|
|
486
|
+
import { join as join5 } from "node:path";
|
|
487
|
+
|
|
488
|
+
// src/core/templates.ts
|
|
489
|
+
var managedStart = "<!-- barry-cache:start -->";
|
|
490
|
+
var managedEnd = "<!-- barry-cache:end -->";
|
|
491
|
+
function managedBlock(body) {
|
|
492
|
+
return `${managedStart}
|
|
493
|
+
${body.trim()}
|
|
494
|
+
${managedEnd}
|
|
495
|
+
`;
|
|
496
|
+
}
|
|
497
|
+
function applyManagedBlock(existing, body) {
|
|
498
|
+
const nextBlock = managedBlock(body);
|
|
499
|
+
const start = existing.indexOf(managedStart);
|
|
500
|
+
const end = existing.indexOf(managedEnd);
|
|
501
|
+
if (start >= 0 && end >= start) {
|
|
502
|
+
const afterEnd = end + managedEnd.length;
|
|
503
|
+
return `${existing.slice(0, start)}${nextBlock}${existing.slice(afterEnd).replace(/^\n/, "")}`;
|
|
504
|
+
}
|
|
505
|
+
const prefix = existing.trim().length > 0 ? `${existing.trimEnd()}
|
|
506
|
+
|
|
507
|
+
` : "";
|
|
508
|
+
return `${prefix}${nextBlock}`;
|
|
509
|
+
}
|
|
510
|
+
var agentInstructions = `
|
|
511
|
+
## Barry Cache
|
|
512
|
+
|
|
513
|
+
Barry Cache remembers this repo through source-backed context files.
|
|
514
|
+
|
|
515
|
+
Start task context with:
|
|
516
|
+
|
|
517
|
+
\`\`\`bash
|
|
518
|
+
barry-cache resume --task "<task>"
|
|
519
|
+
\`\`\`
|
|
520
|
+
|
|
521
|
+
Use focused retrieval during work:
|
|
522
|
+
|
|
523
|
+
\`\`\`bash
|
|
524
|
+
barry-cache route --task "<task>"
|
|
525
|
+
barry-cache search --query "<query>"
|
|
526
|
+
barry-cache load --route "<route>"
|
|
527
|
+
\`\`\`
|
|
528
|
+
|
|
529
|
+
When context files change, run:
|
|
530
|
+
|
|
531
|
+
\`\`\`bash
|
|
532
|
+
barry-cache validate
|
|
533
|
+
\`\`\`
|
|
534
|
+
|
|
535
|
+
Before handing off substantial work, record factual evidence:
|
|
536
|
+
|
|
537
|
+
\`\`\`bash
|
|
538
|
+
barry-cache finalize --status success --summary "<summary>"
|
|
539
|
+
\`\`\`
|
|
540
|
+
`;
|
|
541
|
+
var indexMd = `# Context Index
|
|
542
|
+
|
|
543
|
+
This directory stores source-backed context for coding agents and humans.
|
|
544
|
+
|
|
545
|
+
Use:
|
|
546
|
+
|
|
547
|
+
\`\`\`bash
|
|
548
|
+
barry-cache resume --task "<task>"
|
|
549
|
+
barry-cache validate
|
|
550
|
+
\`\`\`
|
|
551
|
+
|
|
552
|
+
## Routes
|
|
553
|
+
|
|
554
|
+
Feature context packs live in \`docs/context/features/*\`.
|
|
555
|
+
`;
|
|
556
|
+
var logMd = `# Context Log
|
|
557
|
+
|
|
558
|
+
Barry Cache records reviewed context changes here.
|
|
559
|
+
`;
|
|
560
|
+
var maintenanceMd = `# Context Maintenance
|
|
561
|
+
|
|
562
|
+
- Keep project truth in Git.
|
|
563
|
+
- Add source-backed facts to feature \`FACTS.jsonl\` files.
|
|
564
|
+
- Use ADRs for decisions that change architecture.
|
|
565
|
+
- Treat \`.context-state/\` as operational memory, not canonical truth.
|
|
566
|
+
- Run \`barry-cache validate\` after context changes.
|
|
567
|
+
|
|
568
|
+
## Save an agent session
|
|
569
|
+
|
|
570
|
+
When a Codex, Claude, Cursor, Copilot, Gemini, or other agent session contains useful project memory, ask the agent to save it into Barry Cache using this policy:
|
|
571
|
+
|
|
572
|
+
\`\`\`text
|
|
573
|
+
Save this session to Barry Cache.
|
|
574
|
+
|
|
575
|
+
Rules:
|
|
576
|
+
1. Record the session outcome with barry-cache finalize.
|
|
577
|
+
2. Promote only source-backed implementation facts into docs/context/features/*/FACTS.jsonl.
|
|
578
|
+
3. Put uncertain notes, blockers, and next steps in operational memory, not canonical facts.
|
|
579
|
+
4. Update IDMAP.md or KG.adj only when new source IDs or relationships are needed.
|
|
580
|
+
5. Run barry-cache validate before finishing.
|
|
581
|
+
\`\`\`
|
|
582
|
+
|
|
583
|
+
Recommended command for the session outcome:
|
|
584
|
+
|
|
585
|
+
\`\`\`bash
|
|
586
|
+
barry-cache finalize --status success --summary "<what changed or what was learned>"
|
|
587
|
+
\`\`\`
|
|
588
|
+
`;
|
|
589
|
+
var readmeMd = `# Barry Cache Context
|
|
590
|
+
|
|
591
|
+
Barry Cache keeps repo context source-backed, validated, and easy for agents to load.
|
|
592
|
+
|
|
593
|
+
## Reasoning
|
|
594
|
+
|
|
595
|
+
This directory is the canonical project memory for Barry Cache. It keeps durable implementation context in Git so humans and agents can review the same source-backed facts instead of relying on private assistant memory or stale chat history.
|
|
596
|
+
|
|
597
|
+
Barry separates three concerns: \`docs/context/\` is reviewed truth, \`.context-state/\` is operational session continuity, and \`.context-cache/\` is disposable retrieval data. Use this structure to explain existing behavior, route tasks, validate facts, and resume agent work without loading the whole repo.
|
|
598
|
+
`;
|
|
599
|
+
var conceptOverviewMd = `# Project Context Model
|
|
600
|
+
|
|
601
|
+
Barry Cache separates canonical context, operational state, and generated caches.
|
|
602
|
+
|
|
603
|
+
- Canonical context lives in \`docs/context/\`.
|
|
604
|
+
- Operational continuity lives in \`.context-state/\`.
|
|
605
|
+
- Generated retrieval data lives in \`.context-cache/\`.
|
|
606
|
+
`;
|
|
607
|
+
var factSchema = {
|
|
608
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
609
|
+
title: "Barry Cache fact",
|
|
610
|
+
type: "object",
|
|
611
|
+
required: ["id", "subject", "predicate", "object", "src", "status", "kind", "updated_at"],
|
|
612
|
+
properties: {
|
|
613
|
+
id: { type: "string", minLength: 1 },
|
|
614
|
+
subject: { type: "string", minLength: 1 },
|
|
615
|
+
predicate: { type: "string", minLength: 1 },
|
|
616
|
+
object: { type: "string", minLength: 1 },
|
|
617
|
+
src: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
618
|
+
status: { enum: ["active", "superseded", "deprecated", "missing", "conflict"] },
|
|
619
|
+
kind: { enum: ["implemented", "decision", "constraint", "test", "risk", "open-question"] },
|
|
620
|
+
updated_at: { type: "string" },
|
|
621
|
+
confidence: { enum: ["low", "medium", "high"] },
|
|
622
|
+
tags: { type: "array", items: { type: "string" } }
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
var routeSchema = {
|
|
626
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
627
|
+
title: "Barry Cache route",
|
|
628
|
+
type: "object"
|
|
629
|
+
};
|
|
630
|
+
var workStateSchema = {
|
|
631
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
632
|
+
title: "Barry Cache work state",
|
|
633
|
+
type: "object"
|
|
634
|
+
};
|
|
635
|
+
var strategySchema = {
|
|
636
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
637
|
+
title: "Barry Cache strategy",
|
|
638
|
+
type: "object"
|
|
639
|
+
};
|
|
640
|
+
var failureSchema = {
|
|
641
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
642
|
+
title: "Barry Cache failure",
|
|
643
|
+
type: "object"
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// src/core/init.ts
|
|
647
|
+
var allAgentTargets = ["codex", "cursor", "copilot", "claude", "gemini", "llms"];
|
|
648
|
+
var adapterFiles = [
|
|
649
|
+
{ target: "cursor", path: ".cursor/rules/barry-cache.mdc", content: adapterFile("Cursor") },
|
|
650
|
+
{ target: "copilot", path: ".github/copilot-instructions.md", content: adapterFile("GitHub Copilot") },
|
|
651
|
+
{ target: "claude", path: "CLAUDE.md", content: adapterFile("Claude Code") },
|
|
652
|
+
{ target: "gemini", path: "GEMINI.md", content: adapterFile("Gemini") },
|
|
653
|
+
{ target: "llms", path: "llms.txt", content: llmsTxt() }
|
|
654
|
+
];
|
|
655
|
+
async function initProject(options) {
|
|
656
|
+
const repo = options.repo;
|
|
657
|
+
const dryRun = options.dryRun ?? false;
|
|
658
|
+
const result = { changed: false, written: [], updated: [], skipped: [], dryRun };
|
|
659
|
+
const files = [
|
|
660
|
+
{ path: "docs/context/README.md", content: readmeMd },
|
|
661
|
+
{ path: "docs/context/INDEX.md", content: indexMd },
|
|
662
|
+
{ path: "docs/context/LOG.md", content: logMd },
|
|
663
|
+
{ path: "docs/context/MAINTENANCE.md", content: maintenanceMd },
|
|
664
|
+
{ path: "docs/context/concepts/project-context-model.md", content: conceptOverviewMd },
|
|
665
|
+
{ path: "docs/context/schema/fact.schema.json", content: `${JSON.stringify(factSchema, null, 2)}
|
|
666
|
+
` },
|
|
667
|
+
{ path: "docs/context/schema/route.schema.json", content: `${JSON.stringify(routeSchema, null, 2)}
|
|
668
|
+
` },
|
|
669
|
+
{ path: "docs/context/schema/work-state.schema.json", content: `${JSON.stringify(workStateSchema, null, 2)}
|
|
670
|
+
` },
|
|
671
|
+
{ path: "docs/context/schema/strategy.schema.json", content: `${JSON.stringify(strategySchema, null, 2)}
|
|
672
|
+
` },
|
|
673
|
+
{ path: "docs/context/schema/failure.schema.json", content: `${JSON.stringify(failureSchema, null, 2)}
|
|
674
|
+
` }
|
|
675
|
+
];
|
|
676
|
+
for (const file of files) {
|
|
677
|
+
const status = await writeIfChanged(repoPath(repo, file.path), file.content, dryRun);
|
|
678
|
+
record(result, status, file.path);
|
|
679
|
+
}
|
|
680
|
+
await patchAgentInstructions(repo, dryRun, result, options.agents);
|
|
681
|
+
await patchGitignore(repo, dryRun, result);
|
|
682
|
+
const packageManager = await patchPackageJson(repo, dryRun, result);
|
|
683
|
+
if (packageManager)
|
|
684
|
+
result.packageManager = packageManager;
|
|
685
|
+
if (!dryRun) {
|
|
686
|
+
await mkdir3(join5(repo, ".context-state/work/threads"), { recursive: true });
|
|
687
|
+
await mkdir3(join5(repo, ".context-state/handoffs"), { recursive: true });
|
|
688
|
+
await mkdir3(join5(repo, ".context-state/failures"), { recursive: true });
|
|
689
|
+
await mkdir3(join5(repo, ".context-state/strategies"), { recursive: true });
|
|
690
|
+
await mkdir3(join5(repo, ".context-cache"), { recursive: true });
|
|
691
|
+
}
|
|
692
|
+
result.changed = result.written.length > 0 || result.updated.length > 0;
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
function record(result, status, path) {
|
|
696
|
+
if (status === "written")
|
|
697
|
+
result.written.push(path);
|
|
698
|
+
if (status === "updated")
|
|
699
|
+
result.updated.push(path);
|
|
700
|
+
if (status === "skipped")
|
|
701
|
+
result.skipped.push(path);
|
|
702
|
+
}
|
|
703
|
+
async function patchAgentInstructions(repo, dryRun, result, agents) {
|
|
704
|
+
const selected = new Set(agents ?? allAgentTargets);
|
|
705
|
+
if (selected.has("codex"))
|
|
706
|
+
await patchCodexAgents(repo, dryRun, result);
|
|
707
|
+
for (const file of adapterFiles) {
|
|
708
|
+
if (!selected.has(file.target))
|
|
709
|
+
continue;
|
|
710
|
+
record(result, await writeIfChanged(repoPath(repo, file.path), file.content, dryRun), file.path);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async function patchCodexAgents(repo, dryRun, result) {
|
|
714
|
+
const path = repoPath(repo, "AGENTS.md");
|
|
715
|
+
const existing = await exists(path) ? await readText(path) : "";
|
|
716
|
+
const content = applyManagedBlock(existing, agentInstructions);
|
|
717
|
+
record(result, await writeIfChanged(path, content, dryRun), "AGENTS.md");
|
|
718
|
+
}
|
|
719
|
+
async function patchGitignore(repo, dryRun, result) {
|
|
720
|
+
const path = repoPath(repo, ".gitignore");
|
|
721
|
+
const existing = await exists(path) ? await readText(path) : "";
|
|
722
|
+
const body = `.context-state/
|
|
723
|
+
.context-cache/
|
|
724
|
+
`;
|
|
725
|
+
const content = applyManagedBlock(existing, body);
|
|
726
|
+
record(result, await writeIfChanged(path, content, dryRun), ".gitignore");
|
|
727
|
+
}
|
|
728
|
+
async function patchPackageJson(repo, dryRun, result) {
|
|
729
|
+
const path = repoPath(repo, "package.json");
|
|
730
|
+
if (!await exists(path))
|
|
731
|
+
return;
|
|
732
|
+
const parsed = JSON.parse(await readText(path));
|
|
733
|
+
const scripts = typeof parsed.scripts === "object" && parsed.scripts !== null ? parsed.scripts : {};
|
|
734
|
+
scripts.barry ??= "barry-cache";
|
|
735
|
+
scripts["barry:validate"] ??= "barry-cache validate";
|
|
736
|
+
scripts["barry:resume"] ??= "barry-cache resume";
|
|
737
|
+
scripts["barry:finalize"] ??= "barry-cache finalize";
|
|
738
|
+
parsed.scripts = scripts;
|
|
739
|
+
const devDependencies = typeof parsed.devDependencies === "object" && parsed.devDependencies !== null ? parsed.devDependencies : {};
|
|
740
|
+
devDependencies["barry-cache"] ??= "^0.1.0";
|
|
741
|
+
parsed.devDependencies = devDependencies;
|
|
742
|
+
record(result, await writeIfChanged(path, `${JSON.stringify(parsed, null, 2)}
|
|
743
|
+
`, dryRun), "package.json");
|
|
744
|
+
return await detectPackageManager(repo, parsed);
|
|
745
|
+
}
|
|
746
|
+
async function detectPackageManager(repo, packageJson) {
|
|
747
|
+
const packageManager = typeof packageJson.packageManager === "string" ? packageJson.packageManager.split("@")[0] : "";
|
|
748
|
+
if (packageManager === "bun" || packageManager === "pnpm" || packageManager === "yarn" || packageManager === "npm") {
|
|
749
|
+
return packageManagerHint(packageManager);
|
|
750
|
+
}
|
|
751
|
+
if (await exists(repoPath(repo, "bun.lock")) || await exists(repoPath(repo, "bun.lockb")))
|
|
752
|
+
return packageManagerHint("bun");
|
|
753
|
+
if (await exists(repoPath(repo, "pnpm-lock.yaml")))
|
|
754
|
+
return packageManagerHint("pnpm");
|
|
755
|
+
if (await exists(repoPath(repo, "yarn.lock")))
|
|
756
|
+
return packageManagerHint("yarn");
|
|
757
|
+
if (await exists(repoPath(repo, "package-lock.json")) || await exists(repoPath(repo, "npm-shrinkwrap.json")))
|
|
758
|
+
return packageManagerHint("npm");
|
|
759
|
+
return packageManagerHint("npm");
|
|
760
|
+
}
|
|
761
|
+
function packageManagerHint(name) {
|
|
762
|
+
return {
|
|
763
|
+
name,
|
|
764
|
+
installCommand: `${name} install`
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function adapterFile(agent) {
|
|
768
|
+
return `# Barry Cache for ${agent}
|
|
769
|
+
|
|
770
|
+
Canonical context lives in \`docs/context/\`.
|
|
771
|
+
|
|
772
|
+
Start by running:
|
|
773
|
+
|
|
774
|
+
\`\`\`bash
|
|
775
|
+
barry-cache resume --task "<task>"
|
|
776
|
+
\`\`\`
|
|
777
|
+
|
|
778
|
+
Validate context changes with:
|
|
779
|
+
|
|
780
|
+
\`\`\`bash
|
|
781
|
+
barry-cache validate
|
|
782
|
+
\`\`\`
|
|
783
|
+
`;
|
|
784
|
+
}
|
|
785
|
+
function llmsTxt() {
|
|
786
|
+
return `# Barry Cache
|
|
787
|
+
|
|
788
|
+
## Context
|
|
789
|
+
|
|
790
|
+
- [Context index](docs/context/INDEX.md)
|
|
791
|
+
- [Maintenance](docs/context/MAINTENANCE.md)
|
|
792
|
+
- [Project model](docs/context/concepts/project-context-model.md)
|
|
793
|
+
`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/core/review-model.ts
|
|
797
|
+
import { join as join6 } from "node:path";
|
|
798
|
+
|
|
799
|
+
// src/core/review-tree.ts
|
|
800
|
+
function buildReviewTree(facts) {
|
|
801
|
+
const byRoute = new Map;
|
|
802
|
+
const factIdsByRoute = new Map;
|
|
803
|
+
const factIdsByEntity = new Map;
|
|
804
|
+
const factIdsBySource = new Map;
|
|
805
|
+
const factKeysByRoute = new Map;
|
|
806
|
+
const factKeysByEntity = new Map;
|
|
807
|
+
const factKeysBySource = new Map;
|
|
808
|
+
const groups = [];
|
|
809
|
+
for (const item of facts) {
|
|
810
|
+
const key = factKey(item);
|
|
811
|
+
pushMapArray(byRoute, item.route, item);
|
|
812
|
+
pushMapSet(factIdsByRoute, item.route, item.fact.id);
|
|
813
|
+
pushMapSet(factIdsByEntity, item.fact.subject, item.fact.id);
|
|
814
|
+
pushMapSet(factIdsByEntity, item.fact.object, item.fact.id);
|
|
815
|
+
for (const source of item.fact.src)
|
|
816
|
+
pushMapSet(factIdsBySource, source, item.fact.id);
|
|
817
|
+
pushMapSet(factKeysByRoute, item.route, key);
|
|
818
|
+
pushMapSet(factKeysByEntity, item.fact.subject, key);
|
|
819
|
+
pushMapSet(factKeysByEntity, item.fact.object, key);
|
|
820
|
+
for (const source of item.fact.src)
|
|
821
|
+
pushMapSet(factKeysBySource, source, key);
|
|
822
|
+
}
|
|
823
|
+
const features = [];
|
|
824
|
+
const featureNodes = [];
|
|
825
|
+
for (const [route, routeFacts] of [...byRoute.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
826
|
+
const entityIds = new Set;
|
|
827
|
+
const sourceIds = new Set;
|
|
828
|
+
const statusCounts = {};
|
|
829
|
+
const kindCounts = {};
|
|
830
|
+
const predicateCounts = {};
|
|
831
|
+
for (const item of routeFacts) {
|
|
832
|
+
entityIds.add(item.fact.subject);
|
|
833
|
+
entityIds.add(item.fact.object);
|
|
834
|
+
for (const source of item.fact.src)
|
|
835
|
+
sourceIds.add(source);
|
|
836
|
+
increment(statusCounts, item.fact.status);
|
|
837
|
+
increment(kindCounts, item.fact.kind);
|
|
838
|
+
increment(predicateCounts, item.fact.predicate);
|
|
839
|
+
}
|
|
840
|
+
const summary = {
|
|
841
|
+
slug: route,
|
|
842
|
+
label: titleFromSlug2(route),
|
|
843
|
+
factCount: routeFacts.length,
|
|
844
|
+
entityCount: entityIds.size,
|
|
845
|
+
sourceCount: sourceIds.size,
|
|
846
|
+
statusCounts: sortedRecord(statusCounts),
|
|
847
|
+
kindCounts: sortedRecord(kindCounts),
|
|
848
|
+
predicateCounts: sortedRecord(predicateCounts)
|
|
849
|
+
};
|
|
850
|
+
features.push(summary);
|
|
851
|
+
for (const groupBy of ["kind", "predicate", "source", "status"]) {
|
|
852
|
+
const grouped = groupFacts(routeFacts, groupBy);
|
|
853
|
+
for (const [value, groupFacts] of grouped) {
|
|
854
|
+
groups.push({
|
|
855
|
+
id: `tree:group:${route}:${groupBy}:${slug(value)}`,
|
|
856
|
+
route,
|
|
857
|
+
groupBy,
|
|
858
|
+
value,
|
|
859
|
+
factIds: groupFacts.map((item) => item.fact.id).sort(),
|
|
860
|
+
factKeys: groupFacts.map(factKey).sort()
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
featureNodes.push({
|
|
865
|
+
id: `tree:feature:${route}`,
|
|
866
|
+
kind: "feature",
|
|
867
|
+
label: summary.label,
|
|
868
|
+
route,
|
|
869
|
+
count: routeFacts.length,
|
|
870
|
+
children: []
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
return {
|
|
874
|
+
root: {
|
|
875
|
+
id: "tree:root",
|
|
876
|
+
kind: "root",
|
|
877
|
+
label: "Repository",
|
|
878
|
+
count: facts.length,
|
|
879
|
+
children: featureNodes
|
|
880
|
+
},
|
|
881
|
+
features,
|
|
882
|
+
groups: groups.sort((a, b) => a.route.localeCompare(b.route) || a.groupBy.localeCompare(b.groupBy) || a.value.localeCompare(b.value)),
|
|
883
|
+
factIdsByRoute: mapSetToRecord(factIdsByRoute),
|
|
884
|
+
factIdsByEntity: mapSetToRecord(factIdsByEntity),
|
|
885
|
+
factIdsBySource: mapSetToRecord(factIdsBySource),
|
|
886
|
+
factKeysByRoute: mapSetToRecord(factKeysByRoute),
|
|
887
|
+
factKeysByEntity: mapSetToRecord(factKeysByEntity),
|
|
888
|
+
factKeysBySource: mapSetToRecord(factKeysBySource)
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function groupFacts(facts, groupBy) {
|
|
892
|
+
const grouped = new Map;
|
|
893
|
+
for (const item of facts) {
|
|
894
|
+
const values = groupValues(item, groupBy);
|
|
895
|
+
for (const value of values) {
|
|
896
|
+
if (!grouped.has(value))
|
|
897
|
+
grouped.set(value, []);
|
|
898
|
+
grouped.get(value).push(item);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
for (const [value, groupFacts2] of grouped)
|
|
902
|
+
grouped.set(value, groupFacts2.sort((a, b) => factKey(a).localeCompare(factKey(b))));
|
|
903
|
+
return new Map([...grouped.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
904
|
+
}
|
|
905
|
+
function factKey(item) {
|
|
906
|
+
return `${item.route}::${item.fact.id}`;
|
|
907
|
+
}
|
|
908
|
+
function groupValues(item, groupBy) {
|
|
909
|
+
if (groupBy === "kind")
|
|
910
|
+
return [item.fact.kind];
|
|
911
|
+
if (groupBy === "predicate")
|
|
912
|
+
return [item.fact.predicate];
|
|
913
|
+
if (groupBy === "status")
|
|
914
|
+
return [item.fact.status];
|
|
915
|
+
return item.fact.src.length > 0 ? item.fact.src : ["unsourced"];
|
|
916
|
+
}
|
|
917
|
+
function pushMapArray(map, key, value) {
|
|
918
|
+
if (!map.has(key))
|
|
919
|
+
map.set(key, []);
|
|
920
|
+
map.get(key).push(value);
|
|
921
|
+
}
|
|
922
|
+
function pushMapSet(map, key, value) {
|
|
923
|
+
if (!map.has(key))
|
|
924
|
+
map.set(key, new Set);
|
|
925
|
+
map.get(key).add(value);
|
|
926
|
+
}
|
|
927
|
+
function increment(record2, key) {
|
|
928
|
+
record2[key] = (record2[key] ?? 0) + 1;
|
|
929
|
+
}
|
|
930
|
+
function sortedRecord(record2) {
|
|
931
|
+
return Object.fromEntries(Object.entries(record2).sort(([a], [b]) => a.localeCompare(b)));
|
|
932
|
+
}
|
|
933
|
+
function mapSetToRecord(map) {
|
|
934
|
+
return Object.fromEntries([...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, values]) => [key, [...values].sort()]));
|
|
935
|
+
}
|
|
936
|
+
function titleFromSlug2(slug) {
|
|
937
|
+
return slug.split("-").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
|
938
|
+
}
|
|
939
|
+
function slug(input) {
|
|
940
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "value";
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/core/review-model.ts
|
|
944
|
+
async function buildReviewModel({ repo }) {
|
|
945
|
+
const graph = { nodes: new Map, edges: new Map };
|
|
946
|
+
const warnings = [];
|
|
947
|
+
const facts = [];
|
|
948
|
+
const timeline = [];
|
|
949
|
+
const features = await readFeaturePacks(repo);
|
|
950
|
+
for (const feature of features) {
|
|
951
|
+
addFeature(graph, repo, feature);
|
|
952
|
+
const sourceMap = parseIdMap(feature.idmap);
|
|
953
|
+
addKgEdges(graph, feature);
|
|
954
|
+
for (const fact of feature.facts) {
|
|
955
|
+
facts.push({
|
|
956
|
+
route: feature.slug,
|
|
957
|
+
source: `${rel(repo, join6(feature.dir, "FACTS.jsonl"))}#${fact.id}`,
|
|
958
|
+
fact
|
|
959
|
+
});
|
|
960
|
+
addFact(graph, repo, feature, fact, sourceMap);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
timeline.push(...await addOperationalRecords({
|
|
964
|
+
graph,
|
|
965
|
+
repo,
|
|
966
|
+
kind: "handoff",
|
|
967
|
+
path: ".context-state/handoffs/handoffs.jsonl",
|
|
968
|
+
warnings
|
|
969
|
+
}));
|
|
970
|
+
timeline.push(...await addOperationalRecords({
|
|
971
|
+
graph,
|
|
972
|
+
repo,
|
|
973
|
+
kind: "failure",
|
|
974
|
+
path: ".context-state/failures/failure_patterns.jsonl",
|
|
975
|
+
warnings
|
|
976
|
+
}));
|
|
977
|
+
timeline.push(...await addOperationalRecords({
|
|
978
|
+
graph,
|
|
979
|
+
repo,
|
|
980
|
+
kind: "failure",
|
|
981
|
+
path: ".context-state/failures/failures.jsonl",
|
|
982
|
+
warnings
|
|
983
|
+
}));
|
|
984
|
+
timeline.push(...await addOperationalRecords({
|
|
985
|
+
graph,
|
|
986
|
+
repo,
|
|
987
|
+
kind: "strategy",
|
|
988
|
+
path: ".context-state/strategies/strategies.jsonl",
|
|
989
|
+
warnings
|
|
990
|
+
}));
|
|
991
|
+
timeline.sort((a, b) => (b.timestamp ?? "").localeCompare(a.timestamp ?? "") || a.id.localeCompare(b.id));
|
|
992
|
+
const nodes = [...graph.nodes.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
|
993
|
+
const edges = [...graph.edges.values()].sort((a, b) => a.kind.localeCompare(b.kind) || a.id.localeCompare(b.id));
|
|
994
|
+
const sortedFacts = facts.sort((a, b) => a.fact.id.localeCompare(b.fact.id));
|
|
995
|
+
const tree = buildReviewTree(sortedFacts);
|
|
996
|
+
return {
|
|
997
|
+
generated_at: new Date().toISOString(),
|
|
998
|
+
repo,
|
|
999
|
+
summary: {
|
|
1000
|
+
features: features.length,
|
|
1001
|
+
facts: facts.length,
|
|
1002
|
+
entities: nodes.filter((node) => node.kind === "entity").length,
|
|
1003
|
+
sources: nodes.filter((node) => node.kind === "source").length,
|
|
1004
|
+
handoffs: timeline.filter((item) => item.kind === "handoff").length,
|
|
1005
|
+
failures: timeline.filter((item) => item.kind === "failure").length,
|
|
1006
|
+
strategies: timeline.filter((item) => item.kind === "strategy").length,
|
|
1007
|
+
nodes: nodes.length,
|
|
1008
|
+
edges: edges.length
|
|
1009
|
+
},
|
|
1010
|
+
nodes,
|
|
1011
|
+
edges,
|
|
1012
|
+
facts: sortedFacts,
|
|
1013
|
+
tree,
|
|
1014
|
+
timeline,
|
|
1015
|
+
warnings
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function addFeature(graph, repo, feature) {
|
|
1019
|
+
addNode(graph, {
|
|
1020
|
+
id: `feature:${feature.slug}`,
|
|
1021
|
+
kind: "feature",
|
|
1022
|
+
label: feature.slug,
|
|
1023
|
+
subtitle: firstMarkdownHeading(feature.readme) || "Feature context pack",
|
|
1024
|
+
source: rel(repo, feature.dir),
|
|
1025
|
+
meta: {
|
|
1026
|
+
route: feature.slug,
|
|
1027
|
+
facts: feature.facts.length
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
function addFact(graph, repo, feature, fact, sourceMap) {
|
|
1032
|
+
const factId = `fact:${fact.id}`;
|
|
1033
|
+
addNode(graph, {
|
|
1034
|
+
id: factId,
|
|
1035
|
+
kind: "fact",
|
|
1036
|
+
label: fact.id,
|
|
1037
|
+
subtitle: `${fact.subject} ${fact.predicate} ${fact.object}`,
|
|
1038
|
+
source: `${rel(repo, join6(feature.dir, "FACTS.jsonl"))}#${fact.id}`,
|
|
1039
|
+
meta: {
|
|
1040
|
+
route: feature.slug,
|
|
1041
|
+
status: fact.status,
|
|
1042
|
+
kind: fact.kind,
|
|
1043
|
+
confidence: fact.confidence,
|
|
1044
|
+
updated_at: fact.updated_at,
|
|
1045
|
+
tags: fact.tags ?? []
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
addEdge(graph, {
|
|
1049
|
+
source: `feature:${feature.slug}`,
|
|
1050
|
+
target: factId,
|
|
1051
|
+
kind: "contains",
|
|
1052
|
+
meta: { route: feature.slug }
|
|
1053
|
+
});
|
|
1054
|
+
const subjectId = addEntity(graph, fact.subject);
|
|
1055
|
+
const objectId = addEntity(graph, fact.object);
|
|
1056
|
+
addEdge(graph, {
|
|
1057
|
+
source: factId,
|
|
1058
|
+
target: subjectId,
|
|
1059
|
+
kind: "asserts",
|
|
1060
|
+
label: "subject",
|
|
1061
|
+
meta: { predicate: fact.predicate }
|
|
1062
|
+
});
|
|
1063
|
+
addEdge(graph, {
|
|
1064
|
+
source: factId,
|
|
1065
|
+
target: objectId,
|
|
1066
|
+
kind: "asserts",
|
|
1067
|
+
label: fact.predicate,
|
|
1068
|
+
meta: { predicate: fact.predicate }
|
|
1069
|
+
});
|
|
1070
|
+
for (const source of fact.src) {
|
|
1071
|
+
const resolved = sourceMap.get(source) ?? source;
|
|
1072
|
+
const sourceId = addSource(graph, resolved, { alias: resolved === source ? undefined : source });
|
|
1073
|
+
addEdge(graph, {
|
|
1074
|
+
source: factId,
|
|
1075
|
+
target: sourceId,
|
|
1076
|
+
kind: "cites",
|
|
1077
|
+
label: source,
|
|
1078
|
+
meta: { raw: source }
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
for (const superseded of values(fact.supersedes)) {
|
|
1082
|
+
addEdge(graph, {
|
|
1083
|
+
source: factId,
|
|
1084
|
+
target: `fact:${superseded}`,
|
|
1085
|
+
kind: "supersedes",
|
|
1086
|
+
meta: {}
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
function addKgEdges(graph, feature) {
|
|
1091
|
+
for (const line of feature.graph.split(/\r?\n/)) {
|
|
1092
|
+
const trimmed = line.trim();
|
|
1093
|
+
if (trimmed.length === 0 || trimmed.startsWith("#"))
|
|
1094
|
+
continue;
|
|
1095
|
+
const match = trimmed.match(/^(.+?)\s+([^\s]+)\s+(.+)$/);
|
|
1096
|
+
if (!match)
|
|
1097
|
+
continue;
|
|
1098
|
+
const subject = match[1];
|
|
1099
|
+
const predicate = match[2];
|
|
1100
|
+
const object = match[3];
|
|
1101
|
+
if (!subject || !predicate || !object)
|
|
1102
|
+
continue;
|
|
1103
|
+
const source = addEntity(graph, subject.trim());
|
|
1104
|
+
const target = addEntity(graph, object.trim());
|
|
1105
|
+
addEdge(graph, {
|
|
1106
|
+
source,
|
|
1107
|
+
target,
|
|
1108
|
+
kind: "related-to",
|
|
1109
|
+
label: predicate,
|
|
1110
|
+
meta: { route: feature.slug }
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
async function addOperationalRecords(options) {
|
|
1115
|
+
const raw = await readJsonl(repoPath(options.repo, options.path), options.path, options.warnings);
|
|
1116
|
+
const items = [];
|
|
1117
|
+
raw.forEach((record2, index) => {
|
|
1118
|
+
const id = `${options.kind}:${stringValue2(record2.id) || `${options.path}:${index + 1}`}`;
|
|
1119
|
+
const files = arrayOfStrings2(record2.files);
|
|
1120
|
+
const summary = stringValue2(record2.summary) || stringValue2(record2.title) || "No summary";
|
|
1121
|
+
addNode(options.graph, {
|
|
1122
|
+
id,
|
|
1123
|
+
kind: options.kind,
|
|
1124
|
+
label: truncate(summary, 64),
|
|
1125
|
+
subtitle: stringValue2(record2.status) || options.kind,
|
|
1126
|
+
source: `${options.path}#L${index + 1}`,
|
|
1127
|
+
meta: record2
|
|
1128
|
+
});
|
|
1129
|
+
for (const file of files) {
|
|
1130
|
+
const sourceId = addSource(options.graph, file, {});
|
|
1131
|
+
addEdge(options.graph, {
|
|
1132
|
+
source: id,
|
|
1133
|
+
target: sourceId,
|
|
1134
|
+
kind: "touches",
|
|
1135
|
+
meta: { type: "file" }
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
const item = {
|
|
1139
|
+
id,
|
|
1140
|
+
kind: options.kind,
|
|
1141
|
+
label: truncate(summary, 64),
|
|
1142
|
+
summary,
|
|
1143
|
+
source: `${options.path}#L${index + 1}`,
|
|
1144
|
+
files,
|
|
1145
|
+
meta: record2
|
|
1146
|
+
};
|
|
1147
|
+
const timestamp = stringValue2(record2.updated_at);
|
|
1148
|
+
const status = stringValue2(record2.status);
|
|
1149
|
+
if (timestamp)
|
|
1150
|
+
item.timestamp = timestamp;
|
|
1151
|
+
if (status)
|
|
1152
|
+
item.status = status;
|
|
1153
|
+
items.push(item);
|
|
1154
|
+
});
|
|
1155
|
+
return items;
|
|
1156
|
+
}
|
|
1157
|
+
async function readJsonl(path, displayPath, warnings) {
|
|
1158
|
+
const rows = (await readTextIfExists(path)).split(/\r?\n/);
|
|
1159
|
+
const records = [];
|
|
1160
|
+
rows.forEach((row, index) => {
|
|
1161
|
+
if (row.trim().length === 0)
|
|
1162
|
+
return;
|
|
1163
|
+
try {
|
|
1164
|
+
const parsed = JSON.parse(row);
|
|
1165
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1166
|
+
records.push(parsed);
|
|
1167
|
+
} else {
|
|
1168
|
+
warnings.push(`${displayPath}:${index + 1} is not an object`);
|
|
1169
|
+
}
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1172
|
+
warnings.push(`${displayPath}:${index + 1} ${message}`);
|
|
1173
|
+
}
|
|
1174
|
+
});
|
|
1175
|
+
return records;
|
|
1176
|
+
}
|
|
1177
|
+
function addEntity(graph, label) {
|
|
1178
|
+
const id = `entity:${slug2(label)}`;
|
|
1179
|
+
addNode(graph, {
|
|
1180
|
+
id,
|
|
1181
|
+
kind: "entity",
|
|
1182
|
+
label,
|
|
1183
|
+
meta: {}
|
|
1184
|
+
});
|
|
1185
|
+
return id;
|
|
1186
|
+
}
|
|
1187
|
+
function addSource(graph, label, meta) {
|
|
1188
|
+
const id = `source:${label}`;
|
|
1189
|
+
addNode(graph, {
|
|
1190
|
+
id,
|
|
1191
|
+
kind: "source",
|
|
1192
|
+
label,
|
|
1193
|
+
meta
|
|
1194
|
+
});
|
|
1195
|
+
return id;
|
|
1196
|
+
}
|
|
1197
|
+
function addNode(graph, node) {
|
|
1198
|
+
const existing = graph.nodes.get(node.id);
|
|
1199
|
+
if (!existing) {
|
|
1200
|
+
graph.nodes.set(node.id, node);
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
graph.nodes.set(node.id, {
|
|
1204
|
+
...existing,
|
|
1205
|
+
...node,
|
|
1206
|
+
meta: {
|
|
1207
|
+
...existing.meta,
|
|
1208
|
+
...node.meta
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
function addEdge(graph, edge) {
|
|
1213
|
+
const id = edge.id ?? `${edge.kind}:${edge.source}->${edge.target}:${edge.label ?? ""}`;
|
|
1214
|
+
if (graph.edges.has(id))
|
|
1215
|
+
return;
|
|
1216
|
+
const next = {
|
|
1217
|
+
id,
|
|
1218
|
+
source: edge.source,
|
|
1219
|
+
target: edge.target,
|
|
1220
|
+
kind: edge.kind,
|
|
1221
|
+
meta: edge.meta
|
|
1222
|
+
};
|
|
1223
|
+
if (edge.label !== undefined)
|
|
1224
|
+
next.label = edge.label;
|
|
1225
|
+
graph.edges.set(id, next);
|
|
1226
|
+
}
|
|
1227
|
+
function parseIdMap(input) {
|
|
1228
|
+
const map = new Map;
|
|
1229
|
+
for (const line of input.split(/\r?\n/)) {
|
|
1230
|
+
const trimmed = line.trim();
|
|
1231
|
+
if (trimmed.length === 0 || trimmed.startsWith("#"))
|
|
1232
|
+
continue;
|
|
1233
|
+
const markdown = trimmed.match(/[-*]\s*`([^`]+)`\s*:\s*(.+)$/);
|
|
1234
|
+
const plain = trimmed.match(/[-*]\s*([^:]+)\s*:\s*(.+)$/);
|
|
1235
|
+
const match = markdown ?? plain;
|
|
1236
|
+
if (!match)
|
|
1237
|
+
continue;
|
|
1238
|
+
const rawKey = match[1];
|
|
1239
|
+
const rawValue = match[2];
|
|
1240
|
+
if (!rawKey || !rawValue)
|
|
1241
|
+
continue;
|
|
1242
|
+
const key = stripBackticks(rawKey.trim());
|
|
1243
|
+
const value = stripBackticks(rawValue.trim());
|
|
1244
|
+
if (key.length > 0 && value.length > 0)
|
|
1245
|
+
map.set(key, value);
|
|
1246
|
+
}
|
|
1247
|
+
return map;
|
|
1248
|
+
}
|
|
1249
|
+
function firstMarkdownHeading(input) {
|
|
1250
|
+
const heading = input.split(/\r?\n/).find((line) => line.startsWith("# "));
|
|
1251
|
+
return heading?.replace(/^#\s*/, "").trim() ?? "";
|
|
1252
|
+
}
|
|
1253
|
+
function slug2(input) {
|
|
1254
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1255
|
+
}
|
|
1256
|
+
function stripBackticks(input) {
|
|
1257
|
+
return input.replace(/^`|`$/g, "");
|
|
1258
|
+
}
|
|
1259
|
+
function truncate(input, max) {
|
|
1260
|
+
return input.length <= max ? input : `${input.slice(0, max - 1)}...`;
|
|
1261
|
+
}
|
|
1262
|
+
function stringValue2(value) {
|
|
1263
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
1264
|
+
}
|
|
1265
|
+
function arrayOfStrings2(value) {
|
|
1266
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string" && item.length > 0) : [];
|
|
1267
|
+
}
|
|
1268
|
+
function values(value) {
|
|
1269
|
+
if (!value)
|
|
1270
|
+
return [];
|
|
1271
|
+
return Array.isArray(value) ? value : [value];
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// src/core/review-server.ts
|
|
1275
|
+
import { spawn } from "node:child_process";
|
|
1276
|
+
import { createServer } from "node:http";
|
|
1277
|
+
import { platform } from "node:os";
|
|
1278
|
+
async function startReviewServer(options) {
|
|
1279
|
+
const port = options.port ?? 8787;
|
|
1280
|
+
const server = createServer(async (request, response) => {
|
|
1281
|
+
const url2 = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
1282
|
+
try {
|
|
1283
|
+
if (url2.pathname === "/" || url2.pathname === "/index.html") {
|
|
1284
|
+
send(response, 200, createReviewHtml(), "text/html; charset=utf-8");
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
if (url2.pathname === "/api/model") {
|
|
1288
|
+
const model = await buildReviewModel({ repo: options.repo });
|
|
1289
|
+
send(response, 200, JSON.stringify(model), "application/json; charset=utf-8");
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (url2.pathname === "/assets/review.css") {
|
|
1293
|
+
send(response, 200, reviewCss, "text/css; charset=utf-8");
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (url2.pathname === "/assets/review.js") {
|
|
1297
|
+
send(response, 200, reviewJs, "text/javascript; charset=utf-8");
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
send(response, 404, "Not found", "text/plain; charset=utf-8");
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1303
|
+
send(response, 500, JSON.stringify({ ok: false, error: message }), "application/json; charset=utf-8");
|
|
1304
|
+
}
|
|
1305
|
+
});
|
|
1306
|
+
await new Promise((resolve, reject) => {
|
|
1307
|
+
server.once("error", reject);
|
|
1308
|
+
server.listen(port, "127.0.0.1", () => {
|
|
1309
|
+
server.off("error", reject);
|
|
1310
|
+
resolve();
|
|
1311
|
+
});
|
|
1312
|
+
});
|
|
1313
|
+
const address = server.address();
|
|
1314
|
+
const url = `http://127.0.0.1:${address.port}`;
|
|
1315
|
+
if (options.open)
|
|
1316
|
+
openBrowser(url);
|
|
1317
|
+
return {
|
|
1318
|
+
url,
|
|
1319
|
+
close: () => new Promise((resolve, reject) => {
|
|
1320
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
1321
|
+
})
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
function createReviewHtml() {
|
|
1325
|
+
return `<!doctype html>
|
|
1326
|
+
<html lang="en">
|
|
1327
|
+
<head>
|
|
1328
|
+
<meta charset="utf-8">
|
|
1329
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1330
|
+
<meta name="barry-api" content="/api/model">
|
|
1331
|
+
<title>Barry Cache Review</title>
|
|
1332
|
+
<link rel="stylesheet" href="/assets/review.css">
|
|
1333
|
+
<script src="/assets/review.js" defer></script>
|
|
1334
|
+
</head>
|
|
1335
|
+
<body>
|
|
1336
|
+
<div id="app">
|
|
1337
|
+
<div class="boot">Loading Barry Cache Review...</div>
|
|
1338
|
+
</div>
|
|
1339
|
+
</body>
|
|
1340
|
+
</html>`;
|
|
1341
|
+
}
|
|
1342
|
+
function send(response, status, body, contentType) {
|
|
1343
|
+
response.writeHead(status, {
|
|
1344
|
+
"content-type": contentType,
|
|
1345
|
+
"cache-control": "no-store"
|
|
1346
|
+
});
|
|
1347
|
+
response.end(body);
|
|
1348
|
+
}
|
|
1349
|
+
function openBrowser(url) {
|
|
1350
|
+
const currentPlatform = platform();
|
|
1351
|
+
const command = currentPlatform === "darwin" ? "open" : currentPlatform === "win32" ? "cmd" : "xdg-open";
|
|
1352
|
+
const args = currentPlatform === "win32" ? ["/c", "start", "", url] : [url];
|
|
1353
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
1354
|
+
child.unref();
|
|
1355
|
+
}
|
|
1356
|
+
var reviewCss = `
|
|
1357
|
+
:root {
|
|
1358
|
+
color-scheme: light;
|
|
1359
|
+
--bg: #f7f7f4;
|
|
1360
|
+
--surface: #ffffff;
|
|
1361
|
+
--surface-muted: #f0f0eb;
|
|
1362
|
+
--border: #d8d6cf;
|
|
1363
|
+
--border-strong: #bcb8ad;
|
|
1364
|
+
--text: #20201d;
|
|
1365
|
+
--muted: #666258;
|
|
1366
|
+
--faint: #8a8579;
|
|
1367
|
+
--focus: #3f6f5f;
|
|
1368
|
+
--feature: #6f5f32;
|
|
1369
|
+
--group: #356174;
|
|
1370
|
+
--fact: #235347;
|
|
1371
|
+
--entity: #4f46a3;
|
|
1372
|
+
--source: #8a4b16;
|
|
1373
|
+
--more: #6b6b63;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
* {
|
|
1377
|
+
box-sizing: border-box;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
body {
|
|
1381
|
+
margin: 0;
|
|
1382
|
+
background: var(--bg);
|
|
1383
|
+
color: var(--text);
|
|
1384
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
|
1385
|
+
font-size: 14px;
|
|
1386
|
+
line-height: 1.45;
|
|
1387
|
+
overflow: hidden;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
button,
|
|
1391
|
+
input {
|
|
1392
|
+
font: inherit;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
button {
|
|
1396
|
+
border: 1px solid var(--border);
|
|
1397
|
+
background: var(--surface);
|
|
1398
|
+
color: var(--text);
|
|
1399
|
+
border-radius: 7px;
|
|
1400
|
+
cursor: pointer;
|
|
1401
|
+
min-height: 32px;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
button:hover {
|
|
1405
|
+
border-color: var(--border-strong);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
button.is-active {
|
|
1409
|
+
background: var(--text);
|
|
1410
|
+
border-color: var(--text);
|
|
1411
|
+
color: var(--surface);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
input {
|
|
1415
|
+
width: 100%;
|
|
1416
|
+
border: 1px solid var(--border);
|
|
1417
|
+
background: var(--surface);
|
|
1418
|
+
color: var(--text);
|
|
1419
|
+
border-radius: 7px;
|
|
1420
|
+
min-height: 34px;
|
|
1421
|
+
padding: 6px 9px;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
input:focus,
|
|
1425
|
+
button:focus {
|
|
1426
|
+
outline: 2px solid rgba(63, 111, 95, 0.18);
|
|
1427
|
+
outline-offset: 1px;
|
|
1428
|
+
border-color: var(--focus);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
.boot {
|
|
1432
|
+
padding: 24px;
|
|
1433
|
+
color: var(--muted);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
.shell {
|
|
1437
|
+
min-height: 100dvh;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
.main {
|
|
1441
|
+
min-width: 0;
|
|
1442
|
+
display: grid;
|
|
1443
|
+
grid-template-rows: auto minmax(420px, 1fr);
|
|
1444
|
+
height: 100dvh;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
.topbar {
|
|
1448
|
+
min-height: 58px;
|
|
1449
|
+
border-bottom: 1px solid var(--border);
|
|
1450
|
+
background: var(--surface);
|
|
1451
|
+
display: flex;
|
|
1452
|
+
align-items: center;
|
|
1453
|
+
justify-content: space-between;
|
|
1454
|
+
flex-wrap: wrap;
|
|
1455
|
+
gap: 16px;
|
|
1456
|
+
padding: 8px 20px;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
.brand {
|
|
1460
|
+
display: flex;
|
|
1461
|
+
align-items: center;
|
|
1462
|
+
gap: 8px;
|
|
1463
|
+
font-weight: 650;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
.mark {
|
|
1467
|
+
width: 26px;
|
|
1468
|
+
height: 26px;
|
|
1469
|
+
border: 1px solid var(--border-strong);
|
|
1470
|
+
border-radius: 6px;
|
|
1471
|
+
display: grid;
|
|
1472
|
+
place-items: center;
|
|
1473
|
+
font-size: 13px;
|
|
1474
|
+
background: var(--surface-muted);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
button.mark {
|
|
1478
|
+
padding: 0;
|
|
1479
|
+
min-height: 26px;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
h1 {
|
|
1483
|
+
margin: 0;
|
|
1484
|
+
font-size: 17px;
|
|
1485
|
+
line-height: 1.2;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
.header-count {
|
|
1489
|
+
color: var(--muted);
|
|
1490
|
+
font-size: 13px;
|
|
1491
|
+
font-weight: 520;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
.topbar-meta {
|
|
1495
|
+
color: var(--muted);
|
|
1496
|
+
font-size: 13px;
|
|
1497
|
+
text-align: right;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
.workspace {
|
|
1501
|
+
min-height: 0;
|
|
1502
|
+
position: relative;
|
|
1503
|
+
overflow: hidden;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
.workspace.is-left-panel-closed .feature-panel,
|
|
1507
|
+
.workspace.is-inspector-closed .inspector {
|
|
1508
|
+
display: none;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.feature-backdrop {
|
|
1512
|
+
display: none;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
.feature-panel {
|
|
1516
|
+
position: absolute;
|
|
1517
|
+
inset: 0 auto 0 0;
|
|
1518
|
+
z-index: 4;
|
|
1519
|
+
width: 300px;
|
|
1520
|
+
min-width: 0;
|
|
1521
|
+
overflow: auto;
|
|
1522
|
+
border-right: 1px solid var(--border);
|
|
1523
|
+
background: var(--surface);
|
|
1524
|
+
padding: 16px;
|
|
1525
|
+
display: flex;
|
|
1526
|
+
flex-direction: column;
|
|
1527
|
+
gap: 16px;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
.panel-section {
|
|
1531
|
+
display: flex;
|
|
1532
|
+
flex-direction: column;
|
|
1533
|
+
gap: 8px;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
.search-row {
|
|
1537
|
+
display: grid;
|
|
1538
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
1539
|
+
gap: 6px;
|
|
1540
|
+
align-items: center;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
.search-row:not(.has-query) {
|
|
1544
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
.search-row:not(.has-query) .search-clear {
|
|
1548
|
+
display: none;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
.search-clear {
|
|
1552
|
+
width: 34px;
|
|
1553
|
+
padding: 0;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
.search-clear:disabled {
|
|
1557
|
+
color: var(--faint);
|
|
1558
|
+
cursor: default;
|
|
1559
|
+
opacity: 0.5;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
.label {
|
|
1563
|
+
color: var(--muted);
|
|
1564
|
+
font-size: 12px;
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
.feature-list,
|
|
1568
|
+
.segment-list,
|
|
1569
|
+
.summary-list {
|
|
1570
|
+
display: flex;
|
|
1571
|
+
flex-direction: column;
|
|
1572
|
+
gap: 4px;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
.feature-button,
|
|
1576
|
+
.summary-row {
|
|
1577
|
+
width: 100%;
|
|
1578
|
+
display: grid;
|
|
1579
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
1580
|
+
gap: 10px;
|
|
1581
|
+
align-items: center;
|
|
1582
|
+
text-align: left;
|
|
1583
|
+
padding: 7px 8px;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
.feature-name {
|
|
1587
|
+
min-width: 0;
|
|
1588
|
+
overflow: hidden;
|
|
1589
|
+
text-overflow: ellipsis;
|
|
1590
|
+
white-space: nowrap;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
.count {
|
|
1594
|
+
color: var(--faint);
|
|
1595
|
+
font-variant-numeric: tabular-nums;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
.is-active .count {
|
|
1599
|
+
color: rgba(255, 255, 255, 0.72);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
.segment-list {
|
|
1603
|
+
display: grid;
|
|
1604
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
.segment-button {
|
|
1608
|
+
padding: 5px 7px;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
.tree-wrap {
|
|
1612
|
+
position: absolute;
|
|
1613
|
+
inset: 0;
|
|
1614
|
+
z-index: 1;
|
|
1615
|
+
min-width: 0;
|
|
1616
|
+
min-height: 0;
|
|
1617
|
+
background: #fbfbf9;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
.canvas-controls {
|
|
1621
|
+
position: absolute;
|
|
1622
|
+
top: 12px;
|
|
1623
|
+
right: 12px;
|
|
1624
|
+
z-index: 3;
|
|
1625
|
+
display: flex;
|
|
1626
|
+
align-items: center;
|
|
1627
|
+
gap: 4px;
|
|
1628
|
+
border: 1px solid var(--border);
|
|
1629
|
+
border-radius: 8px;
|
|
1630
|
+
background: var(--surface);
|
|
1631
|
+
padding: 4px;
|
|
1632
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
.tool-group {
|
|
1636
|
+
display: flex;
|
|
1637
|
+
align-items: center;
|
|
1638
|
+
gap: 6px;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
.tool-button {
|
|
1642
|
+
min-width: 36px;
|
|
1643
|
+
padding: 4px 8px;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
.workspace:not(.is-inspector-closed) .canvas-controls {
|
|
1647
|
+
right: 372px;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
#tree-canvas {
|
|
1651
|
+
position: absolute;
|
|
1652
|
+
inset: 0;
|
|
1653
|
+
width: 100%;
|
|
1654
|
+
height: 100%;
|
|
1655
|
+
min-width: 0;
|
|
1656
|
+
min-height: 0;
|
|
1657
|
+
overflow: hidden;
|
|
1658
|
+
cursor: grab;
|
|
1659
|
+
touch-action: none;
|
|
1660
|
+
contain: layout paint;
|
|
1661
|
+
user-select: none;
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
#tree-canvas svg {
|
|
1665
|
+
width: 100%;
|
|
1666
|
+
height: 100%;
|
|
1667
|
+
display: block;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
#tree-canvas.is-panning {
|
|
1671
|
+
cursor: grabbing;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
.tree-empty {
|
|
1675
|
+
color: var(--muted);
|
|
1676
|
+
padding: 20px;
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
.related-fact-link {
|
|
1680
|
+
fill: none;
|
|
1681
|
+
stroke: #d8d6cf;
|
|
1682
|
+
stroke-width: 0.9;
|
|
1683
|
+
stroke-opacity: 0.34;
|
|
1684
|
+
pointer-events: none;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.related-fact-link.is-external {
|
|
1688
|
+
stroke-dasharray: 4 5;
|
|
1689
|
+
stroke-linecap: round;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.related-fact-link.is-selected {
|
|
1693
|
+
stroke-opacity: 0.68;
|
|
1694
|
+
stroke-width: 1.1;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
.related-fact-link.is-hovered {
|
|
1698
|
+
stroke: #5c584f;
|
|
1699
|
+
stroke-opacity: 0.92;
|
|
1700
|
+
stroke-width: 2;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
.tree-edge {
|
|
1704
|
+
fill: none;
|
|
1705
|
+
stroke: #c8c3b6;
|
|
1706
|
+
stroke-width: 1.2;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
.tree-edge.is-selected {
|
|
1710
|
+
stroke: #565148;
|
|
1711
|
+
stroke-width: 1.9;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
.tree-node {
|
|
1715
|
+
cursor: pointer;
|
|
1716
|
+
user-select: none;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.tree-node rect,
|
|
1720
|
+
.tree-node circle {
|
|
1721
|
+
stroke: #ffffff;
|
|
1722
|
+
stroke-width: 2;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
.tree-node text {
|
|
1726
|
+
fill: var(--text);
|
|
1727
|
+
font-size: 12px;
|
|
1728
|
+
pointer-events: none;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
.tree-node .node-subtitle {
|
|
1732
|
+
fill: var(--muted);
|
|
1733
|
+
font-size: 11px;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
.tree-node-card text {
|
|
1737
|
+
fill: #ffffff;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
.tree-node-card .node-subtitle {
|
|
1741
|
+
fill: rgba(255, 255, 255, 0.74);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
.tree-node.is-selected rect,
|
|
1745
|
+
.tree-node.is-selected circle {
|
|
1746
|
+
stroke: #20201d;
|
|
1747
|
+
stroke-width: 2.5;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
.tree-node.is-dimmed {
|
|
1751
|
+
opacity: 0.28;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
.tree-node:hover rect,
|
|
1755
|
+
.tree-node:hover circle {
|
|
1756
|
+
stroke: #20201d;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
.inspector {
|
|
1760
|
+
position: absolute;
|
|
1761
|
+
inset: 0 0 0 auto;
|
|
1762
|
+
z-index: 4;
|
|
1763
|
+
width: 360px;
|
|
1764
|
+
min-width: 0;
|
|
1765
|
+
min-height: 0;
|
|
1766
|
+
border-left: 1px solid var(--border);
|
|
1767
|
+
background: var(--surface);
|
|
1768
|
+
display: grid;
|
|
1769
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
.inspector-header {
|
|
1773
|
+
display: grid;
|
|
1774
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
1775
|
+
gap: 10px;
|
|
1776
|
+
align-items: start;
|
|
1777
|
+
border-bottom: 1px solid var(--border);
|
|
1778
|
+
padding: 18px 18px 12px;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
.inspector-close {
|
|
1782
|
+
width: 32px;
|
|
1783
|
+
padding: 0;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
.inspector-body {
|
|
1787
|
+
min-height: 0;
|
|
1788
|
+
overflow: auto;
|
|
1789
|
+
padding: 14px 18px 18px;
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
.inspector-title {
|
|
1793
|
+
font-size: 16px;
|
|
1794
|
+
font-weight: 650;
|
|
1795
|
+
margin: 0;
|
|
1796
|
+
word-break: break-word;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
.inspector-subtitle {
|
|
1800
|
+
color: var(--muted);
|
|
1801
|
+
margin: 0 0 16px;
|
|
1802
|
+
word-break: break-word;
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
.kv {
|
|
1806
|
+
display: grid;
|
|
1807
|
+
grid-template-columns: 98px minmax(0, 1fr);
|
|
1808
|
+
gap: 7px 10px;
|
|
1809
|
+
margin-top: 14px;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
.kv dt {
|
|
1813
|
+
color: var(--muted);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
.kv dd {
|
|
1817
|
+
margin: 0;
|
|
1818
|
+
word-break: break-word;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
.related-list {
|
|
1822
|
+
margin-top: 18px;
|
|
1823
|
+
border-top: 1px solid var(--border);
|
|
1824
|
+
padding-top: 14px;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
.related-title {
|
|
1828
|
+
color: var(--muted);
|
|
1829
|
+
font-size: 12px;
|
|
1830
|
+
margin-bottom: 8px;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
.related-chip {
|
|
1834
|
+
display: inline-block;
|
|
1835
|
+
border: 1px solid var(--border);
|
|
1836
|
+
border-radius: 6px;
|
|
1837
|
+
margin: 0 5px 5px 0;
|
|
1838
|
+
padding: 2px 6px;
|
|
1839
|
+
font-size: 12px;
|
|
1840
|
+
font: inherit;
|
|
1841
|
+
color: var(--muted);
|
|
1842
|
+
background: var(--surface);
|
|
1843
|
+
min-height: 0;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
.related-chip[data-related-fact-id],
|
|
1847
|
+
.related-chip[data-related-fact-key],
|
|
1848
|
+
.related-chip[data-related-show-all] {
|
|
1849
|
+
cursor: pointer;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
.related-chip[data-related-fact-id]:hover,
|
|
1853
|
+
.related-chip[data-related-fact-key]:hover,
|
|
1854
|
+
.related-chip[data-related-show-all]:hover {
|
|
1855
|
+
border-color: var(--border-strong);
|
|
1856
|
+
color: var(--text);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
.related-chip.is-current-feature {
|
|
1860
|
+
border-color: var(--fact);
|
|
1861
|
+
color: #ffffff;
|
|
1862
|
+
background: var(--fact);
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
.related-chip.is-current-feature:hover {
|
|
1866
|
+
border-color: #173d34;
|
|
1867
|
+
color: #ffffff;
|
|
1868
|
+
background: #173d34;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
.related-chip.is-cross-feature {
|
|
1872
|
+
border-color: var(--border);
|
|
1873
|
+
color: var(--muted);
|
|
1874
|
+
background: var(--surface);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
.related-chip.is-cross-feature:hover {
|
|
1878
|
+
border-color: var(--border-strong);
|
|
1879
|
+
color: var(--text);
|
|
1880
|
+
background: var(--surface);
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
.related-more {
|
|
1884
|
+
color: var(--text);
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
.tab {
|
|
1888
|
+
min-height: 30px;
|
|
1889
|
+
padding: 5px 10px;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
.status {
|
|
1893
|
+
display: inline-block;
|
|
1894
|
+
border: 1px solid var(--border);
|
|
1895
|
+
border-radius: 6px;
|
|
1896
|
+
padding: 1px 5px;
|
|
1897
|
+
font-size: 12px;
|
|
1898
|
+
color: var(--muted);
|
|
1899
|
+
background: var(--surface);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
.empty {
|
|
1903
|
+
color: var(--muted);
|
|
1904
|
+
padding: 18px;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
@media (max-width: 1100px) {
|
|
1908
|
+
.main {
|
|
1909
|
+
height: 100dvh;
|
|
1910
|
+
min-height: 100dvh;
|
|
1911
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
.workspace {
|
|
1915
|
+
min-height: 0;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
.feature-panel {
|
|
1919
|
+
width: 280px;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
.inspector {
|
|
1923
|
+
width: 340px;
|
|
1924
|
+
border-left: 1px solid var(--border);
|
|
1925
|
+
border-top: 0;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.workspace:not(.is-inspector-closed) .canvas-controls {
|
|
1929
|
+
right: 352px;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
@media (max-width: 760px) {
|
|
1934
|
+
body {
|
|
1935
|
+
overflow: hidden;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
.main {
|
|
1939
|
+
height: 100dvh;
|
|
1940
|
+
min-height: 100dvh;
|
|
1941
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
.topbar {
|
|
1945
|
+
align-items: flex-start;
|
|
1946
|
+
flex-direction: column;
|
|
1947
|
+
padding: 12px 14px;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
.topbar-meta {
|
|
1951
|
+
text-align: left;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
.workspace {
|
|
1955
|
+
min-height: 0;
|
|
1956
|
+
overflow: hidden;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
.workspace:not(.is-left-panel-closed) .feature-backdrop {
|
|
1960
|
+
display: block;
|
|
1961
|
+
position: fixed;
|
|
1962
|
+
inset: 0;
|
|
1963
|
+
z-index: 18;
|
|
1964
|
+
border: 0;
|
|
1965
|
+
border-radius: 0;
|
|
1966
|
+
background: rgba(32, 32, 29, 0.28);
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
.feature-panel {
|
|
1970
|
+
position: fixed;
|
|
1971
|
+
inset: 0 auto 0 0;
|
|
1972
|
+
z-index: 19;
|
|
1973
|
+
width: min(320px, calc(100vw - 52px));
|
|
1974
|
+
max-width: 100vw;
|
|
1975
|
+
border-right: 0;
|
|
1976
|
+
border-bottom: 0;
|
|
1977
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
.inspector {
|
|
1981
|
+
inset: auto 0 0 0;
|
|
1982
|
+
width: auto;
|
|
1983
|
+
height: clamp(220px, 38dvh, 360px);
|
|
1984
|
+
border-left: 0;
|
|
1985
|
+
border-top: 1px solid var(--border);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
.workspace:not(.is-inspector-closed) .canvas-controls {
|
|
1989
|
+
right: 12px;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
`;
|
|
1993
|
+
var reviewJs = `
|
|
1994
|
+
(function () {
|
|
1995
|
+
var state = {
|
|
1996
|
+
model: null,
|
|
1997
|
+
query: "",
|
|
1998
|
+
groupBy: "kind",
|
|
1999
|
+
selectedFeature: null,
|
|
2000
|
+
selectedId: null,
|
|
2001
|
+
selectedTreeUid: null,
|
|
2002
|
+
expandedFactId: null,
|
|
2003
|
+
transitionExpandedFactId: null,
|
|
2004
|
+
showAllRelatedFacts: false,
|
|
2005
|
+
expandedGroups: {},
|
|
2006
|
+
leftPanelOpen: true,
|
|
2007
|
+
responsivePanelInitialized: false,
|
|
2008
|
+
transformInitialized: false,
|
|
2009
|
+
inspectorOpen: true,
|
|
2010
|
+
transform: { x: 44, y: 48, scale: 1 },
|
|
2011
|
+
visibleTree: null,
|
|
2012
|
+
isPanning: false,
|
|
2013
|
+
panStart: null,
|
|
2014
|
+
panMoved: false,
|
|
2015
|
+
suppressNextTreeClick: false,
|
|
2016
|
+
treeAnimationFrame: null,
|
|
2017
|
+
hoveredRelatedFactKey: null
|
|
2018
|
+
};
|
|
2019
|
+
|
|
2020
|
+
var groupOrder = ["kind", "predicate", "source", "status"];
|
|
2021
|
+
var groupLabels = {
|
|
2022
|
+
kind: "Kind",
|
|
2023
|
+
predicate: "Predicate",
|
|
2024
|
+
source: "Source",
|
|
2025
|
+
status: "Status"
|
|
2026
|
+
};
|
|
2027
|
+
var nodeColors = {
|
|
2028
|
+
feature: "#6f5f32",
|
|
2029
|
+
group: "#356174",
|
|
2030
|
+
fact: "#235347",
|
|
2031
|
+
entity: "#4f46a3",
|
|
2032
|
+
source: "#8a4b16",
|
|
2033
|
+
more: "#6b6b63"
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
function load() {
|
|
2037
|
+
fetch("/api/model")
|
|
2038
|
+
.then(function (response) {
|
|
2039
|
+
if (!response.ok) throw new Error("Review API returned " + response.status);
|
|
2040
|
+
return response.json();
|
|
2041
|
+
})
|
|
2042
|
+
.then(function (model) {
|
|
2043
|
+
state.model = model;
|
|
2044
|
+
initializeSelection(model);
|
|
2045
|
+
render();
|
|
2046
|
+
})
|
|
2047
|
+
.catch(function (error) {
|
|
2048
|
+
document.getElementById("app").innerHTML = '<div class="empty">' + escapeHtml(error.message) + "</div>";
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function initializeSelection(model) {
|
|
2053
|
+
var features = model.tree && model.tree.features ? model.tree.features : [];
|
|
2054
|
+
var selected = features.find(function (feature) { return feature.slug === state.selectedFeature; }) || features[0] || null;
|
|
2055
|
+
state.selectedFeature = selected ? selected.slug : null;
|
|
2056
|
+
if (!state.selectedId && selected) state.selectedId = featureTreeId(selected.slug);
|
|
2057
|
+
if (state.selectedId && state.selectedId.indexOf("tree:feature:") === 0 && selected) {
|
|
2058
|
+
state.selectedId = featureTreeId(selected.slug);
|
|
2059
|
+
}
|
|
2060
|
+
if (!state.selectedTreeUid && selected) state.selectedTreeUid = featureTreeId(selected.slug);
|
|
2061
|
+
if (!state.transformInitialized) {
|
|
2062
|
+
state.transform = defaultTreeTransform();
|
|
2063
|
+
state.transformInitialized = true;
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function render() {
|
|
2068
|
+
var model = state.model;
|
|
2069
|
+
if (!model) return;
|
|
2070
|
+
applyResponsivePanelDefaults();
|
|
2071
|
+
document.getElementById("app").innerHTML =
|
|
2072
|
+
'<div class="shell">' +
|
|
2073
|
+
'<main class="main">' +
|
|
2074
|
+
'<header class="topbar">' +
|
|
2075
|
+
'<div class="brand"><button class="mark" type="button" data-toggle-left-panel="true" aria-label="Toggle feature panel" title="Toggle feature panel">B</button><h1>Memory Review</h1><span class="header-count">' + escapeHtml(headerFactCount(model)) + '</span></div>' +
|
|
2076
|
+
'<div class="tool-group">' +
|
|
2077
|
+
'<div class="topbar-meta">' + escapeHtml(formatDate(model.generated_at)) + ' · ' + escapeHtml(shortRepo(model.repo)) + '</div>' +
|
|
2078
|
+
'<button class="tab" id="refresh" type="button">Refresh</button>' +
|
|
2079
|
+
'</div>' +
|
|
2080
|
+
'</header>' +
|
|
2081
|
+
'<section class="' + workspaceClass() + '">' +
|
|
2082
|
+
'<button class="feature-backdrop" type="button" data-feature-backdrop="true" aria-label="Close feature panel"></button>' +
|
|
2083
|
+
featurePanelHtml(model) +
|
|
2084
|
+
'<section class="tree-wrap">' +
|
|
2085
|
+
'<div class="canvas-controls" aria-label="Tree controls">' +
|
|
2086
|
+
'<button class="tool-button" id="fit-tree" type="button">Fit</button>' +
|
|
2087
|
+
'<button class="tool-button" id="zoom-out" type="button">-</button>' +
|
|
2088
|
+
'<button class="tool-button" id="zoom-in" type="button">+</button>' +
|
|
2089
|
+
'</div>' +
|
|
2090
|
+
'<div id="tree-canvas"></div>' +
|
|
2091
|
+
'</section>' +
|
|
2092
|
+
'<aside class="inspector" id="inspector"></aside>' +
|
|
2093
|
+
'</section>' +
|
|
2094
|
+
'</main>' +
|
|
2095
|
+
'</div>';
|
|
2096
|
+
|
|
2097
|
+
bind();
|
|
2098
|
+
drawTree();
|
|
2099
|
+
renderInspector();
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
function featurePanelHtml(model) {
|
|
2103
|
+
var features = model.tree && model.tree.features ? model.tree.features : [];
|
|
2104
|
+
return '<aside class="feature-panel">' +
|
|
2105
|
+
'<div class="panel-section">' +
|
|
2106
|
+
'<label class="label" for="search">Search memory</label>' +
|
|
2107
|
+
'<div class="search-row' + (state.query ? " has-query" : "") + '">' +
|
|
2108
|
+
'<input id="search" value="' + attr(state.query) + '" autocomplete="off">' +
|
|
2109
|
+
'<button class="search-clear" type="button" data-clear-search="true" aria-label="Clear search" title="Clear search"' + (state.query ? "" : " disabled") + '>x</button>' +
|
|
2110
|
+
'</div>' +
|
|
2111
|
+
'</div>' +
|
|
2112
|
+
'<div class="panel-section">' +
|
|
2113
|
+
'<div class="label">Features</div>' +
|
|
2114
|
+
'<div class="feature-list">' +
|
|
2115
|
+
(features.length === 0 ? '<div class="empty">No memory facts found.</div>' : features.map(featureButton).join("")) +
|
|
2116
|
+
'</div>' +
|
|
2117
|
+
'</div>' +
|
|
2118
|
+
'<div class="panel-section">' +
|
|
2119
|
+
'<div class="label">Group by</div>' +
|
|
2120
|
+
'<div class="segment-list">' +
|
|
2121
|
+
groupOrder.map(function (groupBy) { return groupButton(groupBy); }).join("") +
|
|
2122
|
+
'</div>' +
|
|
2123
|
+
'</div>' +
|
|
2124
|
+
'<div class="panel-section">' +
|
|
2125
|
+
'<div class="label">Summary</div>' +
|
|
2126
|
+
'<div class="summary-list">' +
|
|
2127
|
+
summaryLine("Features", model.summary.features) +
|
|
2128
|
+
summaryLine("Facts", model.summary.facts) +
|
|
2129
|
+
summaryLine("Entities", model.summary.entities) +
|
|
2130
|
+
summaryLine("Sources", model.summary.sources) +
|
|
2131
|
+
'</div>' +
|
|
2132
|
+
'</div>' +
|
|
2133
|
+
'</aside>';
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function bind() {
|
|
2137
|
+
document.getElementById("search").addEventListener("input", function (event) {
|
|
2138
|
+
cancelTreeAnimation();
|
|
2139
|
+
state.query = event.target.value;
|
|
2140
|
+
updateSearchClearButton();
|
|
2141
|
+
drawTree();
|
|
2142
|
+
renderInspector();
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
var clearSearchButton = document.querySelector("[data-clear-search]");
|
|
2146
|
+
if (clearSearchButton) clearSearchButton.addEventListener("click", clearSearch);
|
|
2147
|
+
|
|
2148
|
+
var leftToggle = document.querySelector("[data-toggle-left-panel]");
|
|
2149
|
+
if (leftToggle) leftToggle.addEventListener("click", toggleLeftPanel);
|
|
2150
|
+
|
|
2151
|
+
var featureBackdrop = document.querySelector("[data-feature-backdrop]");
|
|
2152
|
+
if (featureBackdrop) featureBackdrop.addEventListener("click", closeLeftPanel);
|
|
2153
|
+
|
|
2154
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-feature]"), function (button) {
|
|
2155
|
+
button.addEventListener("click", function () {
|
|
2156
|
+
cancelTreeAnimation();
|
|
2157
|
+
var slug = button.getAttribute("data-feature");
|
|
2158
|
+
state.selectedFeature = slug;
|
|
2159
|
+
state.selectedId = featureTreeId(slug);
|
|
2160
|
+
state.selectedTreeUid = featureTreeId(slug);
|
|
2161
|
+
state.expandedFactId = null;
|
|
2162
|
+
state.transitionExpandedFactId = null;
|
|
2163
|
+
state.showAllRelatedFacts = false;
|
|
2164
|
+
state.hoveredRelatedFactKey = null;
|
|
2165
|
+
openInspector();
|
|
2166
|
+
state.expandedGroups = {};
|
|
2167
|
+
state.transform = defaultTreeTransform();
|
|
2168
|
+
if (isMobileLayout()) state.leftPanelOpen = false;
|
|
2169
|
+
render();
|
|
2170
|
+
});
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
Array.prototype.forEach.call(document.querySelectorAll("[data-group-by]"), function (button) {
|
|
2174
|
+
button.addEventListener("click", function () {
|
|
2175
|
+
cancelTreeAnimation();
|
|
2176
|
+
state.groupBy = button.getAttribute("data-group-by");
|
|
2177
|
+
state.selectedId = state.selectedFeature ? featureTreeId(state.selectedFeature) : null;
|
|
2178
|
+
state.selectedTreeUid = state.selectedId;
|
|
2179
|
+
state.expandedFactId = null;
|
|
2180
|
+
state.transitionExpandedFactId = null;
|
|
2181
|
+
state.showAllRelatedFacts = false;
|
|
2182
|
+
state.hoveredRelatedFactKey = null;
|
|
2183
|
+
openInspector();
|
|
2184
|
+
state.expandedGroups = {};
|
|
2185
|
+
state.transform = defaultTreeTransform();
|
|
2186
|
+
render();
|
|
2187
|
+
});
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
document.getElementById("refresh").addEventListener("click", function () {
|
|
2191
|
+
cancelTreeAnimation();
|
|
2192
|
+
load();
|
|
2193
|
+
});
|
|
2194
|
+
document.getElementById("fit-tree").addEventListener("click", fitTree);
|
|
2195
|
+
document.getElementById("zoom-in").addEventListener("click", function () { zoomAt(1.16); });
|
|
2196
|
+
document.getElementById("zoom-out").addEventListener("click", function () { zoomAt(0.86); });
|
|
2197
|
+
bindCanvas();
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function clearSearch() {
|
|
2201
|
+
if (!state.query) return;
|
|
2202
|
+
cancelTreeAnimation();
|
|
2203
|
+
state.query = "";
|
|
2204
|
+
var input = document.getElementById("search");
|
|
2205
|
+
if (input) input.value = "";
|
|
2206
|
+
updateSearchClearButton();
|
|
2207
|
+
drawTree();
|
|
2208
|
+
renderInspector();
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function updateSearchClearButton() {
|
|
2212
|
+
var button = document.querySelector("[data-clear-search]");
|
|
2213
|
+
if (button) button.disabled = !state.query;
|
|
2214
|
+
var row = document.querySelector(".search-row");
|
|
2215
|
+
if (row) row.classList.toggle("has-query", Boolean(state.query));
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
function toggleLeftPanel() {
|
|
2219
|
+
state.leftPanelOpen = !state.leftPanelOpen;
|
|
2220
|
+
render();
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function closeLeftPanel() {
|
|
2224
|
+
if (!state.leftPanelOpen) return;
|
|
2225
|
+
state.leftPanelOpen = false;
|
|
2226
|
+
render();
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
function closeInspector() {
|
|
2230
|
+
state.hoveredRelatedFactKey = null;
|
|
2231
|
+
state.inspectorOpen = false;
|
|
2232
|
+
render();
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function openInspector() {
|
|
2236
|
+
state.inspectorOpen = true;
|
|
2237
|
+
syncWorkspaceClass();
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function bindCanvas() {
|
|
2241
|
+
var canvas = document.getElementById("tree-canvas");
|
|
2242
|
+
canvas.addEventListener("wheel", function (event) {
|
|
2243
|
+
event.preventDefault();
|
|
2244
|
+
var rect = canvas.getBoundingClientRect();
|
|
2245
|
+
zoomAt(event.deltaY > 0 ? 0.88 : 1.12, event.clientX - rect.left, event.clientY - rect.top);
|
|
2246
|
+
}, { passive: false });
|
|
2247
|
+
|
|
2248
|
+
canvas.addEventListener("pointerdown", function (event) {
|
|
2249
|
+
if (event.button !== undefined && event.button !== 0 && event.button !== 1) return;
|
|
2250
|
+
if (event.button === 1) event.preventDefault();
|
|
2251
|
+
cancelTreeAnimation();
|
|
2252
|
+
state.isPanning = true;
|
|
2253
|
+
state.panMoved = false;
|
|
2254
|
+
state.suppressNextTreeClick = false;
|
|
2255
|
+
state.panStart = {
|
|
2256
|
+
pointerId: event.pointerId,
|
|
2257
|
+
button: event.button,
|
|
2258
|
+
startTreeUid: treeUidFromEvent(event),
|
|
2259
|
+
x: event.clientX,
|
|
2260
|
+
y: event.clientY,
|
|
2261
|
+
startX: state.transform.x,
|
|
2262
|
+
startY: state.transform.y
|
|
2263
|
+
};
|
|
2264
|
+
canvas.classList.add("is-panning");
|
|
2265
|
+
if (canvas.setPointerCapture) canvas.setPointerCapture(event.pointerId);
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
canvas.addEventListener("pointermove", function (event) {
|
|
2269
|
+
if (!state.isPanning || !state.panStart) return;
|
|
2270
|
+
var dx = event.clientX - state.panStart.x;
|
|
2271
|
+
var dy = event.clientY - state.panStart.y;
|
|
2272
|
+
if (Math.abs(dx) + Math.abs(dy) > 3) state.panMoved = true;
|
|
2273
|
+
state.transform.x = state.panStart.startX + dx;
|
|
2274
|
+
state.transform.y = state.panStart.startY + dy;
|
|
2275
|
+
paintTreeFrame();
|
|
2276
|
+
});
|
|
2277
|
+
|
|
2278
|
+
canvas.addEventListener("pointerup", endPan);
|
|
2279
|
+
canvas.addEventListener("pointercancel", endPan);
|
|
2280
|
+
|
|
2281
|
+
function endPan(event) {
|
|
2282
|
+
if (!state.isPanning) return;
|
|
2283
|
+
var wasMoved = state.panMoved;
|
|
2284
|
+
var startTreeUid = state.panStart ? state.panStart.startTreeUid : null;
|
|
2285
|
+
var startButton = state.panStart ? state.panStart.button : null;
|
|
2286
|
+
state.isPanning = false;
|
|
2287
|
+
state.suppressNextTreeClick = wasMoved || Boolean(startTreeUid);
|
|
2288
|
+
state.panStart = null;
|
|
2289
|
+
state.panMoved = false;
|
|
2290
|
+
canvas.classList.remove("is-panning");
|
|
2291
|
+
if (canvas.releasePointerCapture) canvas.releasePointerCapture(event.pointerId);
|
|
2292
|
+
if (!wasMoved && startButton === 0 && startTreeUid) selectTreeNodeFromPointer(startTreeUid);
|
|
2293
|
+
if (state.suppressNextTreeClick) {
|
|
2294
|
+
setTimeout(function () {
|
|
2295
|
+
state.suppressNextTreeClick = false;
|
|
2296
|
+
}, 120);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function treeUidFromEvent(event) {
|
|
2302
|
+
if (!event.target || !event.target.closest) return null;
|
|
2303
|
+
var node = event.target.closest("[data-tree-uid]");
|
|
2304
|
+
return node ? node.getAttribute("data-tree-uid") : null;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
function selectTreeNodeFromPointer(treeUid) {
|
|
2308
|
+
if (!state.visibleTree || !treeUid) return;
|
|
2309
|
+
var node = state.visibleTree.byUid[treeUid];
|
|
2310
|
+
if (node) handleTreeNodeClick(node);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
function drawTree() {
|
|
2314
|
+
var canvas = document.getElementById("tree-canvas");
|
|
2315
|
+
var tree = buildVisibleTree();
|
|
2316
|
+
layoutTree(tree);
|
|
2317
|
+
applySearchMatches(tree);
|
|
2318
|
+
state.visibleTree = tree;
|
|
2319
|
+
|
|
2320
|
+
if (tree.nodes.length === 0) {
|
|
2321
|
+
canvas.innerHTML = '<div class="tree-empty">No memory facts found.</div>';
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
var rect = canvas.getBoundingClientRect();
|
|
2326
|
+
var width = Math.max(640, Math.floor(rect.width || 900));
|
|
2327
|
+
var height = Math.max(420, Math.floor(rect.height || 620));
|
|
2328
|
+
var svg = '<svg viewBox="0 0 ' + width + ' ' + height + '" role="img" aria-label="Memory tree">' +
|
|
2329
|
+
'<g id="tree-viewport" transform="' + treeTransformValue() + '">';
|
|
2330
|
+
|
|
2331
|
+
svg += renderRelatedFactLinks(tree);
|
|
2332
|
+
|
|
2333
|
+
tree.edges.forEach(function (edge) {
|
|
2334
|
+
var source = tree.byUid[edge.sourceUid];
|
|
2335
|
+
var target = tree.byUid[edge.targetUid];
|
|
2336
|
+
if (!source || !target) return;
|
|
2337
|
+
var sx = source.x + source.width;
|
|
2338
|
+
var sy = source.y;
|
|
2339
|
+
var tx = target.x;
|
|
2340
|
+
var ty = target.y;
|
|
2341
|
+
var mid = Math.max(50, (tx - sx) * 0.5);
|
|
2342
|
+
var selected = state.selectedTreeUid === source.uid || state.selectedTreeUid === target.uid;
|
|
2343
|
+
svg += '<path class="tree-edge' + (selected ? " is-selected" : "") + '" d="M ' + round(sx) + ' ' + round(sy) + ' C ' + round(sx + mid) + ' ' + round(sy) + ', ' + round(tx - mid) + ' ' + round(ty) + ', ' + round(tx) + ' ' + round(ty) + '"></path>';
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
tree.nodes.forEach(function (node) {
|
|
2347
|
+
svg += renderTreeNode(node);
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
svg += '</g></svg>';
|
|
2351
|
+
canvas.innerHTML = svg;
|
|
2352
|
+
|
|
2353
|
+
Array.prototype.forEach.call(canvas.querySelectorAll("[data-tree-uid]"), function (item) {
|
|
2354
|
+
item.addEventListener("click", function (event) {
|
|
2355
|
+
event.stopPropagation();
|
|
2356
|
+
if (state.suppressNextTreeClick) {
|
|
2357
|
+
state.suppressNextTreeClick = false;
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
var node = state.visibleTree.byUid[item.getAttribute("data-tree-uid")];
|
|
2361
|
+
if (node) handleTreeNodeClick(node);
|
|
2362
|
+
});
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function renderRelatedFactLinks(tree) {
|
|
2367
|
+
return relatedFactLinks(tree).map(function (link) {
|
|
2368
|
+
var source = link.source;
|
|
2369
|
+
var target = link.target;
|
|
2370
|
+
var sx = source.x + source.width;
|
|
2371
|
+
var sy = source.y;
|
|
2372
|
+
var tx = link.target ? target.x + target.width : source.x + source.width + externalLinkLength(link.stubIndex || 0);
|
|
2373
|
+
var ty = link.target ? target.y : source.y + externalLinkOffset(link.stubIndex || 0);
|
|
2374
|
+
var rightRail = link.target ? Math.max(sx, tx) + clamp(Math.abs(ty - sy) * 0.18, 44, 120) : tx + 24;
|
|
2375
|
+
var cx1 = link.target ? rightRail : sx + 42;
|
|
2376
|
+
var cy1 = sy;
|
|
2377
|
+
var cx2 = link.target ? rightRail : tx - 24;
|
|
2378
|
+
var cy2 = ty;
|
|
2379
|
+
var classes = "related-fact-link" +
|
|
2380
|
+
(link.selected ? " is-selected" : "") +
|
|
2381
|
+
(link.external ? " is-external" : "") +
|
|
2382
|
+
(state.hoveredRelatedFactKey === link.factKey ? " is-hovered" : "");
|
|
2383
|
+
return '<path class="' + classes + '" data-link-fact-key="' + attr(link.factKey || "") + '" d="M ' + round(sx) + ' ' + round(sy) + ' C ' + round(cx1) + ' ' + round(cy1) + ', ' + round(cx2) + ' ' + round(cy2) + ', ' + round(tx) + ' ' + round(ty) + '"></path>';
|
|
2384
|
+
}).join("");
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function relatedFactLinks(tree) {
|
|
2388
|
+
var visibleFacts = tree.nodes.filter(function (node) { return node.kind === "fact" && node.factKey; });
|
|
2389
|
+
var visibleByKey = {};
|
|
2390
|
+
var selectedKey = selectedFactKey();
|
|
2391
|
+
var selectedRelatedKeySet = selectedKey ? factKeySet(selectedRelatedFactKeys()) : null;
|
|
2392
|
+
var seen = {};
|
|
2393
|
+
var links = [];
|
|
2394
|
+
visibleFacts.forEach(function (node) {
|
|
2395
|
+
visibleByKey[node.factKey] = node;
|
|
2396
|
+
});
|
|
2397
|
+
visibleFacts.forEach(function (source) {
|
|
2398
|
+
(source.factKeys || []).forEach(function (factKey) {
|
|
2399
|
+
var target = visibleByKey[factKey];
|
|
2400
|
+
if (!target || target.uid === source.uid) return;
|
|
2401
|
+
if (selectedKey) {
|
|
2402
|
+
if (source.factKey !== selectedKey && target.factKey !== selectedKey) return;
|
|
2403
|
+
var otherKey = source.factKey === selectedKey ? target.factKey : source.factKey;
|
|
2404
|
+
if (!selectedRelatedKeySet[otherKey]) return;
|
|
2405
|
+
}
|
|
2406
|
+
var pair = [source.uid, target.uid].sort().join("::");
|
|
2407
|
+
if (seen[pair]) return;
|
|
2408
|
+
seen[pair] = true;
|
|
2409
|
+
links.push({
|
|
2410
|
+
source: source,
|
|
2411
|
+
target: target,
|
|
2412
|
+
factKey: selectedKey ? otherKey : target.factKey,
|
|
2413
|
+
selected: Boolean(selectedKey && (source.factKey === selectedKey || target.factKey === selectedKey))
|
|
2414
|
+
});
|
|
2415
|
+
});
|
|
2416
|
+
});
|
|
2417
|
+
links = links.concat(externalRelatedFactLinks(selectedKey, visibleByKey));
|
|
2418
|
+
links.sort(function (a, b) {
|
|
2419
|
+
if (a.selected !== b.selected) return a.selected ? 1 : -1;
|
|
2420
|
+
var targetA = a.target ? a.target.uid : a.factKey;
|
|
2421
|
+
var targetB = b.target ? b.target.uid : b.factKey;
|
|
2422
|
+
return a.source.uid.localeCompare(b.source.uid) || targetA.localeCompare(targetB);
|
|
2423
|
+
});
|
|
2424
|
+
return limitedRelatedFactLinks(links);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function limitedRelatedFactLinks(links) {
|
|
2428
|
+
var limit = 180;
|
|
2429
|
+
var limited = links.slice(0, limit);
|
|
2430
|
+
if (!state.hoveredRelatedFactKey) return limited;
|
|
2431
|
+
var hasHovered = limited.some(function (link) { return link.factKey === state.hoveredRelatedFactKey; });
|
|
2432
|
+
if (hasHovered) return limited;
|
|
2433
|
+
var hovered = links.find(function (link) { return link.factKey === state.hoveredRelatedFactKey; });
|
|
2434
|
+
if (!hovered) return limited;
|
|
2435
|
+
if (limited.length < limit) {
|
|
2436
|
+
limited.push(hovered);
|
|
2437
|
+
return limited;
|
|
2438
|
+
}
|
|
2439
|
+
limited[limited.length - 1] = hovered;
|
|
2440
|
+
return limited;
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function externalRelatedFactLinks(selectedKey, visibleByKey) {
|
|
2444
|
+
var source = selectedKey ? visibleByKey[selectedKey] : null;
|
|
2445
|
+
if (!source) return [];
|
|
2446
|
+
var externalKeys = visibleRelatedFactKeys().filter(function (factKey) {
|
|
2447
|
+
var parsed = parseFactKey(factKey);
|
|
2448
|
+
return parsed && parsed.route !== source.route && !visibleByKey[factKey];
|
|
2449
|
+
});
|
|
2450
|
+
if (state.hoveredRelatedFactKey && externalKeys.indexOf(state.hoveredRelatedFactKey) < 0) {
|
|
2451
|
+
var hovered = parseFactKey(state.hoveredRelatedFactKey);
|
|
2452
|
+
if (hovered && hovered.route !== source.route && !visibleByKey[state.hoveredRelatedFactKey]) {
|
|
2453
|
+
externalKeys.push(state.hoveredRelatedFactKey);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
var limited = externalKeys.slice(0, 72);
|
|
2457
|
+
if (state.hoveredRelatedFactKey && externalKeys.indexOf(state.hoveredRelatedFactKey) >= 0 && limited.indexOf(state.hoveredRelatedFactKey) < 0) {
|
|
2458
|
+
limited.push(state.hoveredRelatedFactKey);
|
|
2459
|
+
}
|
|
2460
|
+
return limited.map(function (factKey, index) {
|
|
2461
|
+
return {
|
|
2462
|
+
source: source,
|
|
2463
|
+
target: null,
|
|
2464
|
+
factKey: factKey,
|
|
2465
|
+
external: true,
|
|
2466
|
+
selected: true,
|
|
2467
|
+
stubIndex: index
|
|
2468
|
+
};
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function externalLinkLength(index) {
|
|
2473
|
+
return 76 + (index % 4) * 10;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function externalLinkOffset(index) {
|
|
2477
|
+
if (index === 0) return 0;
|
|
2478
|
+
var row = Math.ceil(index / 2);
|
|
2479
|
+
return (index % 2 === 0 ? 1 : -1) * Math.min(64, row * 12);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
function buildVisibleTree() {
|
|
2483
|
+
var feature = currentFeature();
|
|
2484
|
+
var factsByKey = factMap();
|
|
2485
|
+
var nodes = [];
|
|
2486
|
+
var edges = [];
|
|
2487
|
+
var byUid = {};
|
|
2488
|
+
|
|
2489
|
+
if (!feature) return { feature: null, nodes: nodes, edges: edges, byUid: byUid, factCount: 0 };
|
|
2490
|
+
|
|
2491
|
+
var featureUid = featureTreeId(feature.slug);
|
|
2492
|
+
addNode({
|
|
2493
|
+
uid: featureUid,
|
|
2494
|
+
id: featureUid,
|
|
2495
|
+
kind: "feature",
|
|
2496
|
+
label: feature.label,
|
|
2497
|
+
subtitle: feature.factCount + " " + plural(feature.factCount, "fact", "facts"),
|
|
2498
|
+
route: feature.slug,
|
|
2499
|
+
factIds: factIdsForRoute(feature.slug),
|
|
2500
|
+
factKeys: factKeysForRoute(feature.slug),
|
|
2501
|
+
width: 220,
|
|
2502
|
+
height: 48,
|
|
2503
|
+
searchText: [feature.label, feature.slug].join(" ")
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
groupsForFeature(feature.slug).forEach(function (group) {
|
|
2507
|
+
var expanded = isGroupExpanded(group);
|
|
2508
|
+
var groupNode = addNode({
|
|
2509
|
+
uid: group.id,
|
|
2510
|
+
id: group.id,
|
|
2511
|
+
kind: "group",
|
|
2512
|
+
label: group.value,
|
|
2513
|
+
subtitle: groupLabels[group.groupBy],
|
|
2514
|
+
route: group.route,
|
|
2515
|
+
groupBy: group.groupBy,
|
|
2516
|
+
factIds: group.factIds,
|
|
2517
|
+
factKeys: group.factKeys,
|
|
2518
|
+
count: group.factIds.length,
|
|
2519
|
+
width: 220,
|
|
2520
|
+
height: 46,
|
|
2521
|
+
parentUid: featureUid,
|
|
2522
|
+
searchText: [group.value, group.groupBy, group.factIds.join(" ")].join(" ")
|
|
2523
|
+
});
|
|
2524
|
+
addEdge(featureUid, groupNode.uid);
|
|
2525
|
+
|
|
2526
|
+
if (!expanded) {
|
|
2527
|
+
var moreUid = group.id + ":collapsed";
|
|
2528
|
+
var moreNode = addNode({
|
|
2529
|
+
uid: moreUid,
|
|
2530
|
+
id: group.id + ":more",
|
|
2531
|
+
kind: "more",
|
|
2532
|
+
label: group.factIds.length + " " + plural(group.factIds.length, "fact", "facts"),
|
|
2533
|
+
subtitle: "collapsed",
|
|
2534
|
+
route: group.route,
|
|
2535
|
+
factIds: group.factIds,
|
|
2536
|
+
factKeys: group.factKeys,
|
|
2537
|
+
parentGroupUid: group.id,
|
|
2538
|
+
width: 150,
|
|
2539
|
+
height: 40,
|
|
2540
|
+
parentUid: group.id,
|
|
2541
|
+
searchText: group.factIds.join(" ")
|
|
2542
|
+
});
|
|
2543
|
+
addEdge(group.id, moreNode.uid);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
var groupFactIds = group.factIds.filter(function (factId) { return Boolean(factsByKey[factKeyFor(group.route, factId)]); });
|
|
2548
|
+
var visibleFactIds = groupFactIds;
|
|
2549
|
+
var query = state.query.trim().toLowerCase();
|
|
2550
|
+
if (query) {
|
|
2551
|
+
visibleFactIds = visibleFactIds.filter(function (factId) { return factMatchesQuery(factsByKey[factKeyFor(group.route, factId)], query); });
|
|
2552
|
+
}
|
|
2553
|
+
var limit = 25;
|
|
2554
|
+
var renderedFactIds = visibleFactIds.slice(0, limit);
|
|
2555
|
+
[selectedFactId(), state.expandedFactId, state.transitionExpandedFactId].forEach(function (factId) {
|
|
2556
|
+
if (factId && groupFactIds.indexOf(factId) >= 0 && renderedFactIds.indexOf(factId) < 0) renderedFactIds.push(factId);
|
|
2557
|
+
});
|
|
2558
|
+
renderedFactIds.forEach(function (factId) {
|
|
2559
|
+
var item = factsByKey[factKeyFor(group.route, factId)];
|
|
2560
|
+
var fact = item.fact;
|
|
2561
|
+
var factUid = group.id + ":fact:" + fact.id;
|
|
2562
|
+
var factNode = addNode({
|
|
2563
|
+
uid: factUid,
|
|
2564
|
+
id: "fact:" + fact.id,
|
|
2565
|
+
kind: "fact",
|
|
2566
|
+
label: fact.id,
|
|
2567
|
+
subtitle: shortLabel(assertion(fact), 42),
|
|
2568
|
+
route: item.route,
|
|
2569
|
+
factId: fact.id,
|
|
2570
|
+
factKey: factKeyFor(item.route, fact.id),
|
|
2571
|
+
factItem: item,
|
|
2572
|
+
factIds: relatedFactIdsForFact(item),
|
|
2573
|
+
factKeys: relatedFactKeysForFact(item),
|
|
2574
|
+
width: 250,
|
|
2575
|
+
height: 50,
|
|
2576
|
+
parentUid: group.id,
|
|
2577
|
+
searchText: factSearchText(item)
|
|
2578
|
+
});
|
|
2579
|
+
addEdge(group.id, factNode.uid);
|
|
2580
|
+
if (state.selectedTreeUid === factNode.uid || state.expandedFactId === fact.id || state.transitionExpandedFactId === fact.id) addFactLeaves(factNode, item);
|
|
2581
|
+
});
|
|
2582
|
+
|
|
2583
|
+
var remaining = visibleFactIds.filter(function (factId) { return renderedFactIds.indexOf(factId) < 0; });
|
|
2584
|
+
if (remaining.length > 0) {
|
|
2585
|
+
var limitUid = group.id + ":limit";
|
|
2586
|
+
var limitNode = addNode({
|
|
2587
|
+
uid: limitUid,
|
|
2588
|
+
id: group.id + ":limit",
|
|
2589
|
+
kind: "more",
|
|
2590
|
+
label: "+" + remaining.length + " more",
|
|
2591
|
+
subtitle: "limited",
|
|
2592
|
+
route: group.route,
|
|
2593
|
+
factIds: remaining,
|
|
2594
|
+
factKeys: factKeysForRouteAndIds(group.route, remaining),
|
|
2595
|
+
parentGroupUid: group.id,
|
|
2596
|
+
width: 150,
|
|
2597
|
+
height: 40,
|
|
2598
|
+
parentUid: group.id,
|
|
2599
|
+
searchText: remaining.join(" ")
|
|
2600
|
+
});
|
|
2601
|
+
addEdge(group.id, limitNode.uid);
|
|
2602
|
+
}
|
|
2603
|
+
});
|
|
2604
|
+
|
|
2605
|
+
return { feature: feature, nodes: nodes, edges: edges, byUid: byUid, factCount: feature.factCount };
|
|
2606
|
+
|
|
2607
|
+
function addNode(node) {
|
|
2608
|
+
nodes.push(node);
|
|
2609
|
+
byUid[node.uid] = node;
|
|
2610
|
+
return node;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function addEdge(sourceUid, targetUid) {
|
|
2614
|
+
edges.push({ sourceUid: sourceUid, targetUid: targetUid });
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function addFactLeaves(factNode, item) {
|
|
2618
|
+
var fact = item.fact;
|
|
2619
|
+
addEntityLeaf("subject", fact.subject);
|
|
2620
|
+
addEntityLeaf("object", fact.object);
|
|
2621
|
+
fact.src.forEach(function (source, index) {
|
|
2622
|
+
var sourceUid = factNode.uid + ":source:" + index;
|
|
2623
|
+
var sourceNode = addNode({
|
|
2624
|
+
uid: sourceUid,
|
|
2625
|
+
id: "source:" + source,
|
|
2626
|
+
kind: "source",
|
|
2627
|
+
label: source,
|
|
2628
|
+
subtitle: "source",
|
|
2629
|
+
route: item.route,
|
|
2630
|
+
sourceId: source,
|
|
2631
|
+
factIds: factIdsForSource(source),
|
|
2632
|
+
factKeys: factKeysForSource(source),
|
|
2633
|
+
width: 210,
|
|
2634
|
+
height: 42,
|
|
2635
|
+
parentUid: factNode.uid,
|
|
2636
|
+
searchText: source
|
|
2637
|
+
});
|
|
2638
|
+
addEdge(factNode.uid, sourceNode.uid);
|
|
2639
|
+
});
|
|
2640
|
+
|
|
2641
|
+
function addEntityLeaf(role, label) {
|
|
2642
|
+
var entityUid = factNode.uid + ":" + role;
|
|
2643
|
+
var entityNode = addNode({
|
|
2644
|
+
uid: entityUid,
|
|
2645
|
+
id: "entity:" + slug(label),
|
|
2646
|
+
kind: "entity",
|
|
2647
|
+
label: label,
|
|
2648
|
+
subtitle: role,
|
|
2649
|
+
route: item.route,
|
|
2650
|
+
entityId: label,
|
|
2651
|
+
factIds: factIdsForEntity(label),
|
|
2652
|
+
factKeys: factKeysForEntity(label),
|
|
2653
|
+
width: 210,
|
|
2654
|
+
height: 42,
|
|
2655
|
+
parentUid: factNode.uid,
|
|
2656
|
+
searchText: label
|
|
2657
|
+
});
|
|
2658
|
+
addEdge(factNode.uid, entityNode.uid);
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
function layoutTree(tree) {
|
|
2664
|
+
var childrenByParent = {};
|
|
2665
|
+
tree.nodes.forEach(function (node) {
|
|
2666
|
+
if (!childrenByParent[node.parentUid || "root"]) childrenByParent[node.parentUid || "root"] = [];
|
|
2667
|
+
childrenByParent[node.parentUid || "root"].push(node);
|
|
2668
|
+
});
|
|
2669
|
+
|
|
2670
|
+
var row = 0;
|
|
2671
|
+
var rowGap = 68;
|
|
2672
|
+
var columnGap = 278;
|
|
2673
|
+
var root = tree.nodes.find(function (node) { return !node.parentUid; });
|
|
2674
|
+
if (root) assign(root, 0);
|
|
2675
|
+
tree.nodes.forEach(function (node) {
|
|
2676
|
+
if (node.x === undefined || node.y === undefined) assign(node, 0);
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
function assign(node, depth) {
|
|
2680
|
+
node.x = depth * columnGap;
|
|
2681
|
+
var children = childrenByParent[node.uid] || [];
|
|
2682
|
+
if (children.length === 0) {
|
|
2683
|
+
node.y = row * rowGap;
|
|
2684
|
+
row += 1;
|
|
2685
|
+
return node.y;
|
|
2686
|
+
}
|
|
2687
|
+
var first = null;
|
|
2688
|
+
var last = null;
|
|
2689
|
+
children.forEach(function (child) {
|
|
2690
|
+
var childY = assign(child, depth + 1);
|
|
2691
|
+
if (first === null) first = childY;
|
|
2692
|
+
last = childY;
|
|
2693
|
+
});
|
|
2694
|
+
node.y = Math.round(((first || 0) + (last || 0)) / 2);
|
|
2695
|
+
return node.y;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
function applySearchMatches(tree) {
|
|
2700
|
+
var query = state.query.trim().toLowerCase();
|
|
2701
|
+
tree.nodes.forEach(function (node) {
|
|
2702
|
+
node.matches = !query || String(node.searchText || node.label || "").toLowerCase().indexOf(query) >= 0;
|
|
2703
|
+
node.branchMatches = node.matches;
|
|
2704
|
+
});
|
|
2705
|
+
for (var index = tree.nodes.length - 1; index >= 0; index -= 1) {
|
|
2706
|
+
var node = tree.nodes[index];
|
|
2707
|
+
if (!node.branchMatches || !node.parentUid) continue;
|
|
2708
|
+
var parent = tree.byUid[node.parentUid];
|
|
2709
|
+
if (parent) parent.branchMatches = true;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function renderTreeNode(node) {
|
|
2714
|
+
var selected = state.selectedTreeUid === node.uid;
|
|
2715
|
+
var dimmed = state.query.trim() && !node.branchMatches && !selected;
|
|
2716
|
+
var classes = "tree-node tree-node-" + node.kind + (selected ? " is-selected" : "") + (dimmed ? " is-dimmed" : "");
|
|
2717
|
+
var fill = nodeColors[node.kind] || "#555";
|
|
2718
|
+
var x = round(node.x);
|
|
2719
|
+
var y = round(node.y - node.height / 2);
|
|
2720
|
+
var label = escapeHtml(shortLabel(node.label, node.kind === "fact" ? 32 : 28));
|
|
2721
|
+
var subtitle = node.subtitle ? escapeHtml(shortLabel(node.subtitle, node.kind === "fact" ? 38 : 30)) : "";
|
|
2722
|
+
var expandAttr = node.parentGroupUid ? ' data-expand-group="' + attr(node.parentGroupUid) + '"' : "";
|
|
2723
|
+
var html = '<g class="' + classes + '" data-tree-uid="' + attr(node.uid) + '" data-tree-id="' + attr(node.id) + '"' + expandAttr + '>';
|
|
2724
|
+
html += '<title>' + escapeHtml(treeNodeTooltip(node)) + '</title>';
|
|
2725
|
+
|
|
2726
|
+
if (node.kind === "entity" || node.kind === "source") {
|
|
2727
|
+
html += '<circle cx="' + round(node.x + 14) + '" cy="' + round(node.y) + '" r="10" fill="' + fill + '"></circle>';
|
|
2728
|
+
html += '<text x="' + round(node.x + 30) + '" y="' + round(node.y - 2) + '">' + label + '</text>';
|
|
2729
|
+
if (subtitle) html += '<text class="node-subtitle" x="' + round(node.x + 30) + '" y="' + round(node.y + 14) + '">' + subtitle + '</text>';
|
|
2730
|
+
} else {
|
|
2731
|
+
html = html.replace('class="', 'class="tree-node-card ');
|
|
2732
|
+
html += '<rect x="' + x + '" y="' + y + '" width="' + node.width + '" height="' + node.height + '" rx="7" fill="' + fill + '"></rect>';
|
|
2733
|
+
html += '<text x="' + round(node.x + 12) + '" y="' + round(node.y - (subtitle ? 3 : -4)) + '">' + label + '</text>';
|
|
2734
|
+
if (subtitle) html += '<text class="node-subtitle" x="' + round(node.x + 12) + '" y="' + round(node.y + 14) + '">' + subtitle + '</text>';
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
html += '</g>';
|
|
2738
|
+
return html;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function treeNodeTooltip(node) {
|
|
2742
|
+
if (node.factItem) return assertion(node.factItem.fact);
|
|
2743
|
+
if (node.kind === "feature") return node.label + " · " + (node.subtitle || node.id);
|
|
2744
|
+
if (node.kind === "group") return node.label + " · " + (node.count || 0) + " " + plural(node.count || 0, "fact", "facts");
|
|
2745
|
+
if (node.kind === "more") return node.label + (node.subtitle ? " · " + node.subtitle : "");
|
|
2746
|
+
if (node.kind === "entity" || node.kind === "source") return node.label + (node.subtitle ? " · " + node.subtitle : "");
|
|
2747
|
+
return node.subtitle ? node.label + " · " + node.subtitle : node.label;
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
function handleTreeNodeClick(node) {
|
|
2751
|
+
cancelTreeAnimation();
|
|
2752
|
+
state.showAllRelatedFacts = false;
|
|
2753
|
+
state.hoveredRelatedFactKey = null;
|
|
2754
|
+
openInspector();
|
|
2755
|
+
state.selectedTreeUid = node.uid;
|
|
2756
|
+
if (node.kind === "group") {
|
|
2757
|
+
state.selectedId = node.id;
|
|
2758
|
+
state.expandedFactId = null;
|
|
2759
|
+
state.transitionExpandedFactId = null;
|
|
2760
|
+
syncWorkspaceClass();
|
|
2761
|
+
drawTree();
|
|
2762
|
+
renderInspector();
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
if (node.kind === "more" && node.parentGroupUid) {
|
|
2766
|
+
state.selectedId = node.id;
|
|
2767
|
+
state.expandedFactId = null;
|
|
2768
|
+
state.transitionExpandedFactId = null;
|
|
2769
|
+
setGroupExpandedKeepingAnchor(node.parentGroupUid, true);
|
|
2770
|
+
syncWorkspaceClass();
|
|
2771
|
+
renderInspector();
|
|
2772
|
+
return;
|
|
2773
|
+
}
|
|
2774
|
+
if (node.kind === "feature" && node.route) {
|
|
2775
|
+
state.selectedFeature = node.route;
|
|
2776
|
+
state.expandedFactId = null;
|
|
2777
|
+
state.transitionExpandedFactId = null;
|
|
2778
|
+
}
|
|
2779
|
+
if (node.kind === "fact" && node.factId) state.expandedFactId = node.factId;
|
|
2780
|
+
if ((node.kind === "entity" || node.kind === "source") && node.parentUid && state.visibleTree) {
|
|
2781
|
+
var parent = state.visibleTree.byUid[node.parentUid];
|
|
2782
|
+
if (parent && parent.factId) state.expandedFactId = parent.factId;
|
|
2783
|
+
}
|
|
2784
|
+
state.selectedId = node.id;
|
|
2785
|
+
syncWorkspaceClass();
|
|
2786
|
+
drawTree();
|
|
2787
|
+
renderInspector();
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function setGroupExpandedKeepingAnchor(groupUid, expanded) {
|
|
2791
|
+
var anchor = state.visibleTree && state.visibleTree.byUid[groupUid];
|
|
2792
|
+
var anchorScreen = anchor ? {
|
|
2793
|
+
x: state.transform.x + anchor.x * state.transform.scale,
|
|
2794
|
+
y: state.transform.y + anchor.y * state.transform.scale
|
|
2795
|
+
} : null;
|
|
2796
|
+
state.expandedGroups[groupUid] = expanded;
|
|
2797
|
+
drawTree();
|
|
2798
|
+
if (!anchorScreen || !state.visibleTree || !state.visibleTree.byUid[groupUid]) return;
|
|
2799
|
+
var nextAnchor = state.visibleTree.byUid[groupUid];
|
|
2800
|
+
state.transform.x = anchorScreen.x - nextAnchor.x * state.transform.scale;
|
|
2801
|
+
state.transform.y = anchorScreen.y - nextAnchor.y * state.transform.scale;
|
|
2802
|
+
drawTree();
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
function fitTree() {
|
|
2806
|
+
cancelTreeAnimation();
|
|
2807
|
+
var canvas = document.getElementById("tree-canvas");
|
|
2808
|
+
var tree = state.visibleTree;
|
|
2809
|
+
if (!tree || tree.nodes.length === 0) return;
|
|
2810
|
+
var bounds = treeBounds(tree.nodes);
|
|
2811
|
+
var rect = canvas.getBoundingClientRect();
|
|
2812
|
+
var width = Math.max(320, rect.width || 900);
|
|
2813
|
+
var height = Math.max(240, rect.height || 620);
|
|
2814
|
+
var contentWidth = Math.max(1, bounds.maxX - bounds.minX);
|
|
2815
|
+
var contentHeight = Math.max(1, bounds.maxY - bounds.minY);
|
|
2816
|
+
var scale = clamp(Math.min((width - 80) / contentWidth, (height - 80) / contentHeight), 0.3, 1.35);
|
|
2817
|
+
state.transform = {
|
|
2818
|
+
x: Math.round(40 - bounds.minX * scale),
|
|
2819
|
+
y: Math.round(40 - bounds.minY * scale),
|
|
2820
|
+
scale: scale
|
|
2821
|
+
};
|
|
2822
|
+
drawTree();
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
function zoomAt(multiplier, anchorX, anchorY) {
|
|
2826
|
+
cancelTreeAnimation();
|
|
2827
|
+
var canvas = document.getElementById("tree-canvas");
|
|
2828
|
+
var rect = canvas.getBoundingClientRect();
|
|
2829
|
+
var x = anchorX === undefined ? (rect.width || 900) / 2 : anchorX;
|
|
2830
|
+
var y = anchorY === undefined ? (rect.height || 620) / 2 : anchorY;
|
|
2831
|
+
var previous = state.transform.scale;
|
|
2832
|
+
var next = clamp(previous * multiplier, 0.3, 2.4);
|
|
2833
|
+
state.transform.x = x - ((x - state.transform.x) / previous) * next;
|
|
2834
|
+
state.transform.y = y - ((y - state.transform.y) / previous) * next;
|
|
2835
|
+
state.transform.scale = next;
|
|
2836
|
+
paintTreeFrame();
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
function renderInspector() {
|
|
2840
|
+
var inspector = document.getElementById("inspector");
|
|
2841
|
+
if (!inspector) return;
|
|
2842
|
+
var node = selectedTreeNode();
|
|
2843
|
+
if (!node) {
|
|
2844
|
+
var graphNode = selectedGraphNode();
|
|
2845
|
+
if (graphNode) {
|
|
2846
|
+
renderGraphInspector(inspector, graphNode);
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
state.inspectorOpen = false;
|
|
2850
|
+
inspector.innerHTML = "";
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
var rows = [
|
|
2855
|
+
["Type", title(node.kind)],
|
|
2856
|
+
["ID", node.id]
|
|
2857
|
+
];
|
|
2858
|
+
if (node.route) rows.push(["Route", node.route]);
|
|
2859
|
+
if (node.groupBy) rows.push(["Group", groupLabels[node.groupBy] || node.groupBy]);
|
|
2860
|
+
if (node.count !== undefined) rows.push(["Facts", String(node.count)]);
|
|
2861
|
+
if (node.factItem) {
|
|
2862
|
+
var fact = node.factItem.fact;
|
|
2863
|
+
rows.push(["Status", fact.status]);
|
|
2864
|
+
rows.push(["Kind", fact.kind]);
|
|
2865
|
+
rows.push(["Updated", fact.updated_at || ""]);
|
|
2866
|
+
if (fact.confidence) rows.push(["Confidence", fact.confidence]);
|
|
2867
|
+
rows.push(["Source", node.factItem.source]);
|
|
2868
|
+
}
|
|
2869
|
+
if (node.entityId) rows.push(["Entity", node.entityId]);
|
|
2870
|
+
if (node.sourceId) rows.push(["Source", node.sourceId]);
|
|
2871
|
+
|
|
2872
|
+
inspector.innerHTML = inspectorShellHtml(
|
|
2873
|
+
node.label,
|
|
2874
|
+
node.factItem ? assertion(node.factItem.fact) : (node.subtitle || node.id),
|
|
2875
|
+
'<dl class="kv">' + rows.filter(function (row) { return row[1] !== undefined && row[1] !== null && row[1] !== ""; }).map(function (row) {
|
|
2876
|
+
return '<dt>' + escapeHtml(row[0]) + '</dt><dd>' + escapeHtml(row[1]) + '</dd>';
|
|
2877
|
+
}).join("") + '</dl>' +
|
|
2878
|
+
relatedFactsHtml(selectedRelatedFactKeys())
|
|
2879
|
+
);
|
|
2880
|
+
bindInspectorControls(inspector);
|
|
2881
|
+
bindRelatedFactButtons(inspector);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
function renderGraphInspector(inspector, node) {
|
|
2885
|
+
var rows = [
|
|
2886
|
+
["Kind", node.kind],
|
|
2887
|
+
["ID", node.id],
|
|
2888
|
+
["Source", node.source || ""]
|
|
2889
|
+
];
|
|
2890
|
+
Object.keys(node.meta || {}).sort().forEach(function (key) {
|
|
2891
|
+
var value = node.meta[key];
|
|
2892
|
+
if (value === undefined || value === null || value === "") return;
|
|
2893
|
+
rows.push([key, formatValue(value)]);
|
|
2894
|
+
});
|
|
2895
|
+
inspector.innerHTML = inspectorShellHtml(
|
|
2896
|
+
node.label,
|
|
2897
|
+
node.subtitle || node.id,
|
|
2898
|
+
'<dl class="kv">' + rows.map(function (row) {
|
|
2899
|
+
return '<dt>' + escapeHtml(row[0]) + '</dt><dd>' + escapeHtml(row[1]) + '</dd>';
|
|
2900
|
+
}).join("") + '</dl>' +
|
|
2901
|
+
relatedFactsHtml(selectedRelatedFactKeys())
|
|
2902
|
+
);
|
|
2903
|
+
bindInspectorControls(inspector);
|
|
2904
|
+
bindRelatedFactButtons(inspector);
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
function inspectorShellHtml(titleValue, subtitleValue, bodyHtml) {
|
|
2908
|
+
return '<div class="inspector-header">' +
|
|
2909
|
+
'<div><h2 class="inspector-title">' + escapeHtml(titleValue) + '</h2></div>' +
|
|
2910
|
+
'<button class="inspector-close" type="button" data-close-inspector="true" aria-label="Close inspector" title="Close inspector">x</button>' +
|
|
2911
|
+
'</div>' +
|
|
2912
|
+
'<div class="inspector-body"><p class="inspector-subtitle">' + escapeHtml(subtitleValue) + '</p>' + bodyHtml + '</div>';
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
function bindInspectorControls(root) {
|
|
2916
|
+
var closeButton = root.querySelector("[data-close-inspector]");
|
|
2917
|
+
if (closeButton) closeButton.addEventListener("click", closeInspector);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
function relatedFactsHtml(factKeys) {
|
|
2921
|
+
if (!factKeys || factKeys.length === 0) return "";
|
|
2922
|
+
var limit = 24;
|
|
2923
|
+
var visibleKeys = visibleRelatedFactKeys();
|
|
2924
|
+
var visibleCount = state.showAllRelatedFacts ? factKeys.length : Math.min(factKeys.length, limit);
|
|
2925
|
+
var remainingCount = factKeys.length - visibleCount;
|
|
2926
|
+
return '<div class="related-list"><div class="related-title">Related facts</div>' +
|
|
2927
|
+
visibleKeys.map(function (key) {
|
|
2928
|
+
var item = factItemByKey(key);
|
|
2929
|
+
var id = item ? item.fact.id : factIdFromKey(key);
|
|
2930
|
+
var description = relatedFactTooltip(key);
|
|
2931
|
+
return '<button class="' + relatedFactClass(key) + '" type="button" data-related-fact-id="' + attr(id) + '" data-related-fact-key="' + attr(key) + '" title="' + attr(description) + '" aria-label="' + attr(id + ": " + description) + '">' + escapeHtml(id) + '</button>';
|
|
2932
|
+
}).join("") +
|
|
2933
|
+
(remainingCount > 0 ? '<button class="related-chip related-more" type="button" data-related-show-all="true">+' + remainingCount + '</button>' : "") +
|
|
2934
|
+
'</div>';
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
function bindRelatedFactButtons(root) {
|
|
2938
|
+
Array.prototype.forEach.call(root.querySelectorAll("[data-related-fact-key]"), function (button) {
|
|
2939
|
+
var factKey = button.getAttribute("data-related-fact-key");
|
|
2940
|
+
button.addEventListener("mouseenter", function () {
|
|
2941
|
+
setHoveredRelatedFactKey(factKey);
|
|
2942
|
+
});
|
|
2943
|
+
button.addEventListener("mouseleave", function () {
|
|
2944
|
+
setHoveredRelatedFactKey(null);
|
|
2945
|
+
});
|
|
2946
|
+
button.addEventListener("focus", function () {
|
|
2947
|
+
setHoveredRelatedFactKey(factKey);
|
|
2948
|
+
});
|
|
2949
|
+
button.addEventListener("blur", function () {
|
|
2950
|
+
setHoveredRelatedFactKey(null);
|
|
2951
|
+
});
|
|
2952
|
+
button.addEventListener("click", function () {
|
|
2953
|
+
activateRelatedFact(factKey);
|
|
2954
|
+
});
|
|
2955
|
+
});
|
|
2956
|
+
Array.prototype.forEach.call(root.querySelectorAll("[data-related-show-all]"), function (button) {
|
|
2957
|
+
button.addEventListener("click", function () {
|
|
2958
|
+
state.showAllRelatedFacts = true;
|
|
2959
|
+
renderInspector();
|
|
2960
|
+
});
|
|
2961
|
+
});
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
function setHoveredRelatedFactKey(factKey) {
|
|
2965
|
+
if (state.hoveredRelatedFactKey === factKey) return;
|
|
2966
|
+
state.hoveredRelatedFactKey = factKey;
|
|
2967
|
+
drawTree();
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
function activateRelatedFact(factKey) {
|
|
2971
|
+
if (!factKey) return;
|
|
2972
|
+
var item = factItemByKey(factKey);
|
|
2973
|
+
if (!item) return;
|
|
2974
|
+
cancelTreeAnimation();
|
|
2975
|
+
state.showAllRelatedFacts = false;
|
|
2976
|
+
state.hoveredRelatedFactKey = null;
|
|
2977
|
+
openInspector();
|
|
2978
|
+
var previousFeature = state.selectedFeature;
|
|
2979
|
+
var sourceFactId = selectedFactId() || state.expandedFactId;
|
|
2980
|
+
var factId = item.fact.id;
|
|
2981
|
+
var target = prepareRelatedFactTarget(item.route, factId);
|
|
2982
|
+
if (!target) return;
|
|
2983
|
+
state.selectedFeature = target.route;
|
|
2984
|
+
state.selectedId = target.id;
|
|
2985
|
+
state.selectedTreeUid = target.treeUid;
|
|
2986
|
+
state.expandedFactId = target.factId;
|
|
2987
|
+
state.expandedGroups[target.groupUid] = true;
|
|
2988
|
+
state.transitionExpandedFactId = previousFeature === target.route && sourceFactId && sourceFactId !== factId ? sourceFactId : null;
|
|
2989
|
+
if (previousFeature !== target.route) {
|
|
2990
|
+
render();
|
|
2991
|
+
centerTreeNode(target.treeUid, true, finishRelatedFactNavigation);
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
syncWorkspaceClass();
|
|
2995
|
+
drawTree();
|
|
2996
|
+
centerTreeNode(target.treeUid, true, finishRelatedFactNavigation);
|
|
2997
|
+
renderInspector();
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
function finishRelatedFactNavigation() {
|
|
3001
|
+
if (!state.transitionExpandedFactId) return;
|
|
3002
|
+
state.transitionExpandedFactId = null;
|
|
3003
|
+
drawTree();
|
|
3004
|
+
renderInspector();
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function selectFactInTree(route, factId) {
|
|
3008
|
+
var target = prepareRelatedFactTarget(route, factId);
|
|
3009
|
+
if (!target) {
|
|
3010
|
+
state.selectedTreeUid = null;
|
|
3011
|
+
return null;
|
|
3012
|
+
}
|
|
3013
|
+
state.expandedGroups[target.groupUid] = true;
|
|
3014
|
+
state.selectedTreeUid = target.treeUid;
|
|
3015
|
+
return target;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
function prepareRelatedFactTarget(route, factId) {
|
|
3019
|
+
var group = groupsForFeature(route).find(function (candidate) {
|
|
3020
|
+
return candidate.factIds.indexOf(factId) >= 0;
|
|
3021
|
+
});
|
|
3022
|
+
if (!group) return null;
|
|
3023
|
+
return {
|
|
3024
|
+
route: route,
|
|
3025
|
+
factId: factId,
|
|
3026
|
+
id: "fact:" + factId,
|
|
3027
|
+
groupUid: group.id,
|
|
3028
|
+
treeUid: group.id + ":fact:" + factId
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
function centerTreeNode(treeUid, animated, onComplete) {
|
|
3033
|
+
var canvas = document.getElementById("tree-canvas");
|
|
3034
|
+
var tree = state.visibleTree;
|
|
3035
|
+
if (!tree || !treeUid || !tree.byUid[treeUid]) {
|
|
3036
|
+
if (onComplete) onComplete();
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
var node = tree.byUid[treeUid];
|
|
3040
|
+
var rect = canvas.getBoundingClientRect();
|
|
3041
|
+
var width = Math.max(320, rect.width || 900);
|
|
3042
|
+
var height = Math.max(240, rect.height || 620);
|
|
3043
|
+
var targetScale = animated ? clamp(Math.max(state.transform.scale, 1), 0.75, 1.35) : state.transform.scale;
|
|
3044
|
+
var target = centeredTreeTransform(node, width, height, targetScale);
|
|
3045
|
+
if (animated) {
|
|
3046
|
+
animateTreeTransform(target, node, width, height, onComplete);
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
cancelTreeAnimation();
|
|
3050
|
+
state.transform.x = target.x;
|
|
3051
|
+
state.transform.y = target.y;
|
|
3052
|
+
state.transform.scale = target.scale;
|
|
3053
|
+
paintTreeFrame();
|
|
3054
|
+
if (onComplete) onComplete();
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function animateTreeTransform(target, node, width, height, onComplete) {
|
|
3058
|
+
cancelTreeAnimation(true);
|
|
3059
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
3060
|
+
var collapsedTarget = collapseTransitionExpansionBeforeZoomIn(node.uid, width, height, target.scale, target.scale);
|
|
3061
|
+
if (collapsedTarget) target = collapsedTarget.target;
|
|
3062
|
+
state.transform.x = target.x;
|
|
3063
|
+
state.transform.y = target.y;
|
|
3064
|
+
state.transform.scale = target.scale;
|
|
3065
|
+
paintTreeFrame();
|
|
3066
|
+
if (onComplete) onComplete();
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
var start = {
|
|
3070
|
+
x: state.transform.x,
|
|
3071
|
+
y: state.transform.y,
|
|
3072
|
+
scale: state.transform.scale
|
|
3073
|
+
};
|
|
3074
|
+
var midScale = zoomOutScale(start.scale, target.scale);
|
|
3075
|
+
var midStart = zoomAroundViewport(start, width, height, midScale);
|
|
3076
|
+
var midTarget = centeredTreeTransform(node, width, height, midScale);
|
|
3077
|
+
var duration = 1500;
|
|
3078
|
+
var startedAt = null;
|
|
3079
|
+
var zoomInStarted = false;
|
|
3080
|
+
|
|
3081
|
+
function step(timestamp) {
|
|
3082
|
+
if (startedAt === null) startedAt = timestamp;
|
|
3083
|
+
var progress = clamp((timestamp - startedAt) / duration, 0, 1);
|
|
3084
|
+
if (!zoomInStarted && progress >= 0.72) {
|
|
3085
|
+
zoomInStarted = true;
|
|
3086
|
+
var collapsed = collapseTransitionExpansionBeforeZoomIn(node.uid, width, height, midScale, target.scale);
|
|
3087
|
+
if (collapsed) {
|
|
3088
|
+
node = collapsed.node;
|
|
3089
|
+
midTarget = collapsed.midTarget;
|
|
3090
|
+
target = collapsed.target;
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
3093
|
+
var frame = treeTravelFrame(progress, start, midStart, midTarget, target);
|
|
3094
|
+
state.transform.x = frame.x;
|
|
3095
|
+
state.transform.y = frame.y;
|
|
3096
|
+
state.transform.scale = frame.scale;
|
|
3097
|
+
paintTreeFrame();
|
|
3098
|
+
if (progress < 1) {
|
|
3099
|
+
state.treeAnimationFrame = requestAnimationFrame(step);
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
state.transform.x = target.x;
|
|
3103
|
+
state.transform.y = target.y;
|
|
3104
|
+
state.transform.scale = target.scale;
|
|
3105
|
+
paintTreeFrame();
|
|
3106
|
+
state.treeAnimationFrame = null;
|
|
3107
|
+
if (onComplete) onComplete();
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
state.treeAnimationFrame = requestAnimationFrame(step);
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
function collapseTransitionExpansionBeforeZoomIn(treeUid, width, height, midScale, targetScale) {
|
|
3114
|
+
if (!state.transitionExpandedFactId) return null;
|
|
3115
|
+
state.transitionExpandedFactId = null;
|
|
3116
|
+
drawTree();
|
|
3117
|
+
if (!state.visibleTree || !state.visibleTree.byUid[treeUid]) return null;
|
|
3118
|
+
var node = state.visibleTree.byUid[treeUid];
|
|
3119
|
+
var midTarget = centeredTreeTransform(node, width, height, midScale);
|
|
3120
|
+
state.transform.x = midTarget.x;
|
|
3121
|
+
state.transform.y = midTarget.y;
|
|
3122
|
+
state.transform.scale = midTarget.scale;
|
|
3123
|
+
paintTreeFrame();
|
|
3124
|
+
return {
|
|
3125
|
+
node: node,
|
|
3126
|
+
midTarget: midTarget,
|
|
3127
|
+
target: centeredTreeTransform(node, width, height, targetScale)
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
function treeTravelFrame(progress, start, midStart, midTarget, target) {
|
|
3132
|
+
if (progress < 0.32) {
|
|
3133
|
+
return interpolateTransform(start, midStart, easeTreePan(progress / 0.32));
|
|
3134
|
+
}
|
|
3135
|
+
if (progress < 0.72) {
|
|
3136
|
+
return interpolateTransform(midStart, midTarget, easeTreePan((progress - 0.32) / 0.4));
|
|
3137
|
+
}
|
|
3138
|
+
return interpolateTransform(midTarget, target, easeTreePan((progress - 0.72) / 0.28));
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function interpolateTransform(from, to, progress) {
|
|
3142
|
+
return {
|
|
3143
|
+
x: from.x + (to.x - from.x) * progress,
|
|
3144
|
+
y: from.y + (to.y - from.y) * progress,
|
|
3145
|
+
scale: from.scale + (to.scale - from.scale) * progress
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
function paintTreeFrame() {
|
|
3150
|
+
var viewport = document.getElementById("tree-viewport");
|
|
3151
|
+
if (!viewport) {
|
|
3152
|
+
drawTree();
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
viewport.setAttribute("transform", treeTransformValue());
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function treeTransformValue() {
|
|
3159
|
+
return "translate(" + transformNumber(state.transform.x) + " " + transformNumber(state.transform.y) + ") scale(" + transformNumber(state.transform.scale) + ")";
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
function transformNumber(value) {
|
|
3163
|
+
return Math.round(value * 1000) / 1000;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
function centeredTreeTransform(node, width, height, scale) {
|
|
3167
|
+
return {
|
|
3168
|
+
x: Math.round(width / 2 - (node.x + node.width / 2) * scale),
|
|
3169
|
+
y: Math.round(height / 2 - node.y * scale),
|
|
3170
|
+
scale: scale
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
function zoomAroundViewport(transform, width, height, scale) {
|
|
3175
|
+
var centerX = width / 2;
|
|
3176
|
+
var centerY = height / 2;
|
|
3177
|
+
return {
|
|
3178
|
+
x: Math.round(centerX - ((centerX - transform.x) / transform.scale) * scale),
|
|
3179
|
+
y: Math.round(centerY - ((centerY - transform.y) / transform.scale) * scale),
|
|
3180
|
+
scale: scale
|
|
3181
|
+
};
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
function zoomOutScale(startScale, targetScale) {
|
|
3185
|
+
return clamp(Math.min(startScale, targetScale) * 0.62, 0.32, 0.9);
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
function cancelTreeAnimation(keepTransitionExpansion) {
|
|
3189
|
+
if (state.treeAnimationFrame === null) return;
|
|
3190
|
+
if (typeof cancelAnimationFrame === "function") cancelAnimationFrame(state.treeAnimationFrame);
|
|
3191
|
+
state.treeAnimationFrame = null;
|
|
3192
|
+
if (!keepTransitionExpansion) state.transitionExpandedFactId = null;
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
function easeTreePan(progress) {
|
|
3196
|
+
return progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
function selectedRelatedFactKeys() {
|
|
3200
|
+
var node = selectedTreeNode();
|
|
3201
|
+
if (node && node.factKeys) return filterRelatedFactKeys(node.factKeys);
|
|
3202
|
+
var item = selectedFactItem();
|
|
3203
|
+
if (item) return filterRelatedFactKeys(relatedFactKeysForFact(item));
|
|
3204
|
+
if (state.selectedFeature) return filterRelatedFactKeys(factKeysForRoute(state.selectedFeature));
|
|
3205
|
+
return [];
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
function visibleRelatedFactKeys() {
|
|
3209
|
+
var factKeys = selectedRelatedFactKeys();
|
|
3210
|
+
if (state.showAllRelatedFacts) return factKeys;
|
|
3211
|
+
var visible = factKeys.slice(0, 24);
|
|
3212
|
+
if (state.hoveredRelatedFactKey && factKeys.indexOf(state.hoveredRelatedFactKey) >= 0 && visible.indexOf(state.hoveredRelatedFactKey) < 0) {
|
|
3213
|
+
visible.push(state.hoveredRelatedFactKey);
|
|
3214
|
+
}
|
|
3215
|
+
return visible;
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
function filterRelatedFactKeys(factKeys) {
|
|
3219
|
+
var currentFactKey = selectedFactKey();
|
|
3220
|
+
var keys = unique(factKeys);
|
|
3221
|
+
if (!currentFactKey) return keys;
|
|
3222
|
+
return keys.filter(function (value) { return value !== currentFactKey; });
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
function relatedFactIdsForFact(item) {
|
|
3226
|
+
var ids = [item.fact.id];
|
|
3227
|
+
ids = ids.concat(factIdsForEntity(item.fact.subject));
|
|
3228
|
+
ids = ids.concat(factIdsForEntity(item.fact.object));
|
|
3229
|
+
item.fact.src.forEach(function (source) {
|
|
3230
|
+
ids = ids.concat(factIdsForSource(source));
|
|
3231
|
+
});
|
|
3232
|
+
return unique(ids);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
function relatedFactKeysForFact(item) {
|
|
3236
|
+
var keys = [factKeyFor(item.route, item.fact.id)];
|
|
3237
|
+
keys = keys.concat(factKeysForEntity(item.fact.subject));
|
|
3238
|
+
keys = keys.concat(factKeysForEntity(item.fact.object));
|
|
3239
|
+
item.fact.src.forEach(function (source) {
|
|
3240
|
+
keys = keys.concat(factKeysForSource(source));
|
|
3241
|
+
});
|
|
3242
|
+
return unique(keys);
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
function groupsForFeature(route) {
|
|
3246
|
+
return state.model.tree.groups
|
|
3247
|
+
.filter(function (group) { return group.route === route && group.groupBy === state.groupBy; })
|
|
3248
|
+
.sort(function (a, b) {
|
|
3249
|
+
return b.factIds.length - a.factIds.length || a.value.localeCompare(b.value);
|
|
3250
|
+
});
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
function isGroupExpanded(group) {
|
|
3254
|
+
if (!group) return false;
|
|
3255
|
+
if (state.expandedGroups[group.id] !== undefined) return Boolean(state.expandedGroups[group.id]);
|
|
3256
|
+
return group.factIds.length <= 25;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
function currentFeature() {
|
|
3260
|
+
var features = state.model && state.model.tree ? state.model.tree.features : [];
|
|
3261
|
+
return features.find(function (feature) { return feature.slug === state.selectedFeature; }) || features[0] || null;
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
function selectedTreeNode() {
|
|
3265
|
+
var tree = state.visibleTree;
|
|
3266
|
+
if (!tree || !state.selectedTreeUid) return null;
|
|
3267
|
+
return tree.byUid[state.selectedTreeUid] || null;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function selectedGraphNode() {
|
|
3271
|
+
if (!state.selectedId) return null;
|
|
3272
|
+
return state.model.nodes.find(function (node) { return node.id === state.selectedId; }) || null;
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
function selectedFactId() {
|
|
3276
|
+
return state.selectedId && state.selectedId.indexOf("fact:") === 0 ? state.selectedId.slice(5) : null;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
function selectedFactKey() {
|
|
3280
|
+
var node = selectedTreeNode();
|
|
3281
|
+
if (node && node.factKey) return node.factKey;
|
|
3282
|
+
if (node && node.parentUid && state.visibleTree) {
|
|
3283
|
+
var parent = state.visibleTree.byUid[node.parentUid];
|
|
3284
|
+
if (parent && parent.factKey) return parent.factKey;
|
|
3285
|
+
}
|
|
3286
|
+
var factId = selectedFactId();
|
|
3287
|
+
return factId && state.selectedFeature ? factKeyFor(state.selectedFeature, factId) : null;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
function selectedFactItem() {
|
|
3291
|
+
var key = selectedFactKey();
|
|
3292
|
+
return key ? factItemByKey(key) : null;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
function factIdsForRoute(route) {
|
|
3296
|
+
return route && state.model.tree.factIdsByRoute[route] ? state.model.tree.factIdsByRoute[route] : [];
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
function factKeysForRoute(route) {
|
|
3300
|
+
if (!route) return [];
|
|
3301
|
+
if (state.model.tree.factKeysByRoute && state.model.tree.factKeysByRoute[route]) return state.model.tree.factKeysByRoute[route];
|
|
3302
|
+
return factIdsForRoute(route).map(function (id) { return factKeyFor(route, id); });
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
function factKeysForRouteAndIds(route, factIds) {
|
|
3306
|
+
return factIds.map(function (id) { return factKeyFor(route, id); });
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
function factIdsForEntity(entity) {
|
|
3310
|
+
return entity && state.model.tree.factIdsByEntity[entity] ? state.model.tree.factIdsByEntity[entity] : [];
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
function factKeysForEntity(entity) {
|
|
3314
|
+
if (!entity) return [];
|
|
3315
|
+
if (state.model.tree.factKeysByEntity && state.model.tree.factKeysByEntity[entity]) return state.model.tree.factKeysByEntity[entity];
|
|
3316
|
+
return factIdsForEntity(entity).map(function (id) { return factKeyFor(state.selectedFeature || "", id); });
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function factIdsForSource(source) {
|
|
3320
|
+
return source && state.model.tree.factIdsBySource[source] ? state.model.tree.factIdsBySource[source] : [];
|
|
3321
|
+
}
|
|
3322
|
+
|
|
3323
|
+
function factKeysForSource(source) {
|
|
3324
|
+
if (!source) return [];
|
|
3325
|
+
if (state.model.tree.factKeysBySource && state.model.tree.factKeysBySource[source]) return state.model.tree.factKeysBySource[source];
|
|
3326
|
+
return factIdsForSource(source).map(function (id) { return factKeyFor(state.selectedFeature || "", id); });
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
function factMap() {
|
|
3330
|
+
return state.model.facts.reduce(function (map, item) {
|
|
3331
|
+
map[factKeyFor(item.route, item.fact.id)] = item;
|
|
3332
|
+
return map;
|
|
3333
|
+
}, {});
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
function factItemByKey(key) {
|
|
3337
|
+
var parsed = parseFactKey(key);
|
|
3338
|
+
if (!parsed) return null;
|
|
3339
|
+
return state.model.facts.find(function (item) { return item.route === parsed.route && item.fact.id === parsed.id; }) || null;
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
function relatedFactTooltip(key) {
|
|
3343
|
+
var item = factItemByKey(key);
|
|
3344
|
+
return item ? featureLabelForRoute(item.route) + ": " + assertion(item.fact) : factIdFromKey(key);
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3347
|
+
function relatedFactClass(key) {
|
|
3348
|
+
var item = factItemByKey(key);
|
|
3349
|
+
var isCurrentFeature = item && state.selectedFeature && item.route === state.selectedFeature;
|
|
3350
|
+
return "related-chip" + (isCurrentFeature ? " is-current-feature" : " is-cross-feature");
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
function defaultTreeTransform() {
|
|
3354
|
+
return { x: leftPanelSafeTreeX(), y: 48, scale: 1 };
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
function leftPanelSafeTreeX() {
|
|
3358
|
+
if (!state.leftPanelOpen || isMobileLayout()) return 44;
|
|
3359
|
+
return (isCompactDesktopLayout() ? 280 : 300) + 24;
|
|
3360
|
+
}
|
|
3361
|
+
|
|
3362
|
+
function workspaceClass() {
|
|
3363
|
+
var hasInspector = state.inspectorOpen && hasSelection();
|
|
3364
|
+
return "workspace" +
|
|
3365
|
+
(state.leftPanelOpen ? "" : " is-left-panel-closed") +
|
|
3366
|
+
(hasInspector ? "" : " is-inspector-closed");
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function applyResponsivePanelDefaults() {
|
|
3370
|
+
if (state.responsivePanelInitialized) return;
|
|
3371
|
+
state.responsivePanelInitialized = true;
|
|
3372
|
+
if (isMobileLayout()) state.leftPanelOpen = false;
|
|
3373
|
+
}
|
|
3374
|
+
|
|
3375
|
+
function isMobileLayout() {
|
|
3376
|
+
return typeof window !== "undefined" && window.matchMedia && window.matchMedia("(max-width: 760px)").matches;
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
function isCompactDesktopLayout() {
|
|
3380
|
+
return typeof window !== "undefined" && window.matchMedia && window.matchMedia("(max-width: 1100px)").matches;
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
function syncWorkspaceClass() {
|
|
3384
|
+
var workspace = document.querySelector(".workspace");
|
|
3385
|
+
if (workspace) workspace.className = workspaceClass();
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
function hasSelection() {
|
|
3389
|
+
return Boolean(state.selectedTreeUid || state.selectedId || selectedTreeNode() || selectedGraphNode());
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
function factKeyFor(route, id) {
|
|
3393
|
+
return route + "::" + id;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
function parseFactKey(key) {
|
|
3397
|
+
var index = String(key).indexOf("::");
|
|
3398
|
+
if (index < 0) return state.selectedFeature ? { route: state.selectedFeature, id: String(key) } : null;
|
|
3399
|
+
return { route: String(key).slice(0, index), id: String(key).slice(index + 2) };
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
function factIdFromKey(key) {
|
|
3403
|
+
var parsed = parseFactKey(key);
|
|
3404
|
+
return parsed ? parsed.id : String(key);
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
function featureLabelForRoute(route) {
|
|
3408
|
+
var features = state.model && state.model.tree ? state.model.tree.features : [];
|
|
3409
|
+
var feature = features.find(function (item) { return item.slug === route; });
|
|
3410
|
+
return feature ? feature.label : route;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
function factMatchesQuery(item, query) {
|
|
3414
|
+
return factSearchText(item).toLowerCase().indexOf(query) >= 0;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
function factSearchText(item) {
|
|
3418
|
+
var fact = item.fact;
|
|
3419
|
+
return [item.route, item.source, fact.id, fact.subject, fact.predicate, fact.object, fact.status, fact.kind, (fact.tags || []).join(" "), fact.src.join(" ")].join(" ");
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
function assertion(fact) {
|
|
3423
|
+
return fact.subject + " " + fact.predicate + " " + fact.object;
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
function featureButton(feature) {
|
|
3427
|
+
return '<button type="button" class="feature-button' + (state.selectedFeature === feature.slug ? " is-active" : "") + '" data-feature="' + attr(feature.slug) + '">' +
|
|
3428
|
+
'<span class="feature-name">' + escapeHtml(feature.label) + '</span><span class="count">' + feature.factCount + '</span></button>';
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
function groupButton(groupBy) {
|
|
3432
|
+
return '<button type="button" class="segment-button' + (state.groupBy === groupBy ? " is-active" : "") + '" data-group-by="' + attr(groupBy) + '">' + escapeHtml(groupLabels[groupBy]) + '</button>';
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
function summaryLine(label, value) {
|
|
3436
|
+
return '<div class="summary-row"><span>' + escapeHtml(label) + '</span><span class="count">' + value + '</span></div>';
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
function headerFactCount(model) {
|
|
3440
|
+
var feature = currentFeature();
|
|
3441
|
+
var count = feature ? feature.factCount : model.summary.facts;
|
|
3442
|
+
return count + " " + plural(count, "fact", "facts");
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
function treeBounds(nodes) {
|
|
3446
|
+
return nodes.reduce(function (bounds, node) {
|
|
3447
|
+
bounds.minX = Math.min(bounds.minX, node.x);
|
|
3448
|
+
bounds.maxX = Math.max(bounds.maxX, node.x + node.width);
|
|
3449
|
+
bounds.minY = Math.min(bounds.minY, node.y - node.height / 2);
|
|
3450
|
+
bounds.maxY = Math.max(bounds.maxY, node.y + node.height / 2);
|
|
3451
|
+
return bounds;
|
|
3452
|
+
}, { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity });
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
function featureTreeId(slugValue) {
|
|
3456
|
+
return "tree:feature:" + slugValue;
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
function unique(values) {
|
|
3460
|
+
var seen = {};
|
|
3461
|
+
var result = [];
|
|
3462
|
+
values.forEach(function (value) {
|
|
3463
|
+
if (!value || seen[value]) return;
|
|
3464
|
+
seen[value] = true;
|
|
3465
|
+
result.push(value);
|
|
3466
|
+
});
|
|
3467
|
+
return result.sort();
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3470
|
+
function factKeySet(values) {
|
|
3471
|
+
return values.reduce(function (set, value) {
|
|
3472
|
+
if (value) set[value] = true;
|
|
3473
|
+
return set;
|
|
3474
|
+
}, {});
|
|
3475
|
+
}
|
|
3476
|
+
|
|
3477
|
+
function title(input) {
|
|
3478
|
+
return input.charAt(0).toUpperCase() + input.slice(1);
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
function plural(count, singular, pluralValue) {
|
|
3482
|
+
return count === 1 ? singular : pluralValue;
|
|
3483
|
+
}
|
|
3484
|
+
|
|
3485
|
+
function shortRepo(repo) {
|
|
3486
|
+
return repo.split(/[\\\\/]/).filter(Boolean).slice(-2).join("/");
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
function shortLabel(label, max) {
|
|
3490
|
+
var limit = max || 28;
|
|
3491
|
+
return String(label).length > limit ? String(label).slice(0, limit - 1) + "..." : String(label);
|
|
3492
|
+
}
|
|
3493
|
+
|
|
3494
|
+
function formatDate(value) {
|
|
3495
|
+
try {
|
|
3496
|
+
return new Date(value).toLocaleString();
|
|
3497
|
+
} catch (_error) {
|
|
3498
|
+
return value;
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
function formatValue(value) {
|
|
3503
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
function slug(input) {
|
|
3507
|
+
return String(input).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function clamp(value, min, max) {
|
|
3511
|
+
return Math.min(max, Math.max(min, value));
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
function round(value) {
|
|
3515
|
+
return Math.round(value * 100) / 100;
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
function attr(value) {
|
|
3519
|
+
return escapeHtml(String(value)).replace(/"/g, """);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
function escapeHtml(value) {
|
|
3523
|
+
return String(value)
|
|
3524
|
+
.replace(/&/g, "&")
|
|
3525
|
+
.replace(/</g, "<")
|
|
3526
|
+
.replace(/>/g, ">");
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
load();
|
|
3530
|
+
})();
|
|
3531
|
+
`;
|
|
3532
|
+
|
|
3533
|
+
// src/cli.ts
|
|
3534
|
+
class CliArgumentError extends Error {
|
|
3535
|
+
usage;
|
|
3536
|
+
options;
|
|
3537
|
+
constructor(message, details = {}) {
|
|
3538
|
+
super(message);
|
|
3539
|
+
this.name = "CliArgumentError";
|
|
3540
|
+
this.usage = details.usage;
|
|
3541
|
+
this.options = details.options;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
var importSources = ["pulpcut-kb"];
|
|
3545
|
+
var agentTargets = ["all", "none", "codex", "cursor", "copilot", "claude", "gemini", "llms"];
|
|
3546
|
+
var finalizeStatuses = ["success", "partial", "blocked", "failed"];
|
|
3547
|
+
async function main(argv = process.argv.slice(2)) {
|
|
3548
|
+
const parsed = parseArgs(argv);
|
|
3549
|
+
const repo = process.cwd();
|
|
3550
|
+
const json = parsed.flags.get("json") === true;
|
|
3551
|
+
try {
|
|
3552
|
+
switch (parsed.command) {
|
|
3553
|
+
case "init": {
|
|
3554
|
+
const agents = parseAgentTargets(parsed.flags.get("agents") ?? parsed.flags.get("agent"));
|
|
3555
|
+
const initOptions = {
|
|
3556
|
+
repo,
|
|
3557
|
+
yes: parsed.flags.get("yes") === true,
|
|
3558
|
+
dryRun: parsed.flags.get("dry-run") === true
|
|
3559
|
+
};
|
|
3560
|
+
if (agents !== undefined)
|
|
3561
|
+
initOptions.agents = agents;
|
|
3562
|
+
const result = await initProject(initOptions);
|
|
3563
|
+
print(result, json, formatInitMessage(result));
|
|
3564
|
+
break;
|
|
3565
|
+
}
|
|
3566
|
+
case "validate": {
|
|
3567
|
+
const result = await validateProject({ repo });
|
|
3568
|
+
print(result, json, result.ok ? "Barry Cache context is valid." : `Barry Cache found ${result.errors.length} error(s).`);
|
|
3569
|
+
if (!result.ok)
|
|
3570
|
+
process.exitCode = 1;
|
|
3571
|
+
break;
|
|
3572
|
+
}
|
|
3573
|
+
case "routes":
|
|
3574
|
+
print(await routeTask({ repo, task: "" }), json);
|
|
3575
|
+
break;
|
|
3576
|
+
case "route": {
|
|
3577
|
+
const task = requiredString(parsed, "task", commandUsage("route"));
|
|
3578
|
+
print(await routeTask({ repo, task }), json);
|
|
3579
|
+
break;
|
|
3580
|
+
}
|
|
3581
|
+
case "search": {
|
|
3582
|
+
const query = requiredString(parsed, "query", commandUsage("search"));
|
|
3583
|
+
print(await searchContext({ repo, query }), json);
|
|
3584
|
+
break;
|
|
3585
|
+
}
|
|
3586
|
+
case "load": {
|
|
3587
|
+
const route = requiredString(parsed, "route", commandUsage("load"));
|
|
3588
|
+
print(await loadContext({ repo, route }), json);
|
|
3589
|
+
break;
|
|
3590
|
+
}
|
|
3591
|
+
case "resume": {
|
|
3592
|
+
const task = requiredString(parsed, "task", commandUsage("resume"));
|
|
3593
|
+
print(await resumeProject({ repo, task }), json);
|
|
3594
|
+
break;
|
|
3595
|
+
}
|
|
3596
|
+
case "finalize": {
|
|
3597
|
+
const status = optionalChoice(parsed, "status", finalizeStatuses, "success", commandUsage("finalize"));
|
|
3598
|
+
const summary = requiredString(parsed, "summary", commandUsage("finalize"), { status: [...finalizeStatuses] });
|
|
3599
|
+
print(await finalizeProject({ repo, status, summary }), json);
|
|
3600
|
+
break;
|
|
3601
|
+
}
|
|
3602
|
+
case "import": {
|
|
3603
|
+
const source = requiredString(parsed, "source", commandUsage("import"), { source: [...importSources] });
|
|
3604
|
+
const from = requiredString(parsed, "from", commandUsage("import"), { source: [...importSources] });
|
|
3605
|
+
if (!isImportSource(source)) {
|
|
3606
|
+
throw new CliArgumentError(`Unsupported import source: ${source}`, {
|
|
3607
|
+
usage: commandUsage("import"),
|
|
3608
|
+
options: { source: [...importSources] }
|
|
3609
|
+
});
|
|
3610
|
+
}
|
|
3611
|
+
const result = await importPulpcutKb({
|
|
3612
|
+
repo,
|
|
3613
|
+
from,
|
|
3614
|
+
dryRun: parsed.flags.get("dry-run") === true
|
|
3615
|
+
});
|
|
3616
|
+
print(result, json, `Imported ${result.imported} PulpCut KB feature pack${result.imported === 1 ? "" : "s"}.`);
|
|
3617
|
+
break;
|
|
3618
|
+
}
|
|
3619
|
+
case "review": {
|
|
3620
|
+
if (json) {
|
|
3621
|
+
print(await buildReviewModel({ repo }), true);
|
|
3622
|
+
break;
|
|
3623
|
+
}
|
|
3624
|
+
const server = await startReviewServer({
|
|
3625
|
+
repo,
|
|
3626
|
+
port: optionalNumber(parsed, "port", 8787, commandUsage("review")),
|
|
3627
|
+
open: parsed.flags.get("open") === true
|
|
3628
|
+
});
|
|
3629
|
+
console.log(`Barry Cache review running at ${server.url}`);
|
|
3630
|
+
console.log("Press Ctrl+C to stop.");
|
|
3631
|
+
process.once("SIGINT", async () => {
|
|
3632
|
+
await server.close();
|
|
3633
|
+
process.exit(0);
|
|
3634
|
+
});
|
|
3635
|
+
process.once("SIGTERM", async () => {
|
|
3636
|
+
await server.close();
|
|
3637
|
+
process.exit(0);
|
|
3638
|
+
});
|
|
3639
|
+
break;
|
|
3640
|
+
}
|
|
3641
|
+
case "doctor": {
|
|
3642
|
+
const result = await validateProject({ repo });
|
|
3643
|
+
print(result, json, result.ok ? "Barry Cache setup looks healthy." : "Barry Cache setup needs attention.");
|
|
3644
|
+
if (!result.ok)
|
|
3645
|
+
process.exitCode = 1;
|
|
3646
|
+
break;
|
|
3647
|
+
}
|
|
3648
|
+
case "generate-adapters":
|
|
3649
|
+
print({ ok: true, message: "Run barry-cache init to regenerate adapters." }, json);
|
|
3650
|
+
break;
|
|
3651
|
+
case "lint-wiki":
|
|
3652
|
+
print({ ok: true, message: "No wiki lint rules failed." }, json);
|
|
3653
|
+
break;
|
|
3654
|
+
default:
|
|
3655
|
+
if (parsed.command === "help" || parsed.command === "--help" || parsed.command === "-h") {
|
|
3656
|
+
console.log(usageText());
|
|
3657
|
+
} else {
|
|
3658
|
+
console.error(usageText(`Unknown command: ${parsed.command}`));
|
|
3659
|
+
process.exitCode = 1;
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
} catch (error) {
|
|
3663
|
+
if (json) {
|
|
3664
|
+
console.log(JSON.stringify(formatJsonError(error), null, 2));
|
|
3665
|
+
} else {
|
|
3666
|
+
console.error(formatCliError(error));
|
|
3667
|
+
}
|
|
3668
|
+
process.exitCode = 1;
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
function parseArgs(argv) {
|
|
3672
|
+
const [command = "help", ...rest] = argv;
|
|
3673
|
+
const flags = new Map;
|
|
3674
|
+
for (let index = 0;index < rest.length; index++) {
|
|
3675
|
+
const arg = rest[index];
|
|
3676
|
+
if (!arg?.startsWith("--"))
|
|
3677
|
+
continue;
|
|
3678
|
+
const key = arg.slice(2);
|
|
3679
|
+
const next = rest[index + 1];
|
|
3680
|
+
if (next && !next.startsWith("--")) {
|
|
3681
|
+
flags.set(key, next);
|
|
3682
|
+
index++;
|
|
3683
|
+
} else {
|
|
3684
|
+
flags.set(key, true);
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
return { command, flags };
|
|
3688
|
+
}
|
|
3689
|
+
function requiredString(parsed, key, usageValue, options) {
|
|
3690
|
+
const value = parsed.flags.get(key);
|
|
3691
|
+
if (value === true)
|
|
3692
|
+
throw new CliArgumentError(`--${key} requires a value`, { usage: usageValue, options });
|
|
3693
|
+
if (typeof value !== "string" || value.length === 0)
|
|
3694
|
+
throw new CliArgumentError(`Missing required --${key}`, { usage: usageValue, options });
|
|
3695
|
+
return value;
|
|
3696
|
+
}
|
|
3697
|
+
function optionalNumber(parsed, key, fallback, usageValue) {
|
|
3698
|
+
const value = parsed.flags.get(key);
|
|
3699
|
+
if (value === undefined)
|
|
3700
|
+
return fallback;
|
|
3701
|
+
if (typeof value !== "string" || value.length === 0)
|
|
3702
|
+
throw new CliArgumentError(`--${key} requires a number`, { usage: usageValue });
|
|
3703
|
+
const parsedValue = Number(value);
|
|
3704
|
+
if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 65535)
|
|
3705
|
+
throw new CliArgumentError(`--${key} must be a port number`, { usage: usageValue });
|
|
3706
|
+
return parsedValue;
|
|
3707
|
+
}
|
|
3708
|
+
function optionalChoice(parsed, key, choices, fallback, usageValue) {
|
|
3709
|
+
const value = parsed.flags.get(key);
|
|
3710
|
+
if (value === undefined)
|
|
3711
|
+
return fallback;
|
|
3712
|
+
if (typeof value !== "string" || value.length === 0)
|
|
3713
|
+
throw new CliArgumentError(`--${key} requires a value`, { usage: usageValue, options: { [key]: [...choices] } });
|
|
3714
|
+
if (!choices.includes(value)) {
|
|
3715
|
+
throw new CliArgumentError(`Unsupported --${key} value: ${value}`, {
|
|
3716
|
+
usage: usageValue,
|
|
3717
|
+
options: { [key]: [...choices] }
|
|
3718
|
+
});
|
|
3719
|
+
}
|
|
3720
|
+
return value;
|
|
3721
|
+
}
|
|
3722
|
+
function parseAgentTargets(value) {
|
|
3723
|
+
if (value === undefined)
|
|
3724
|
+
return;
|
|
3725
|
+
if (typeof value !== "string")
|
|
3726
|
+
throw new CliArgumentError("--agents requires a comma-separated list", { usage: commandUsage("init"), options: { agents: agentTargets } });
|
|
3727
|
+
const rawTargets = value.split(",").map((item) => item.trim().toLowerCase()).filter(Boolean);
|
|
3728
|
+
if (rawTargets.length === 0)
|
|
3729
|
+
throw new CliArgumentError("--agents requires at least one target", { usage: commandUsage("init"), options: { agents: agentTargets } });
|
|
3730
|
+
if (rawTargets.includes("all")) {
|
|
3731
|
+
if (rawTargets.length > 1)
|
|
3732
|
+
throw new CliArgumentError("--agents all cannot be combined with other targets", { usage: commandUsage("init"), options: { agents: agentTargets } });
|
|
3733
|
+
return;
|
|
3734
|
+
}
|
|
3735
|
+
if (rawTargets.includes("none")) {
|
|
3736
|
+
if (rawTargets.length > 1)
|
|
3737
|
+
throw new CliArgumentError("--agents none cannot be combined with other targets", { usage: commandUsage("init"), options: { agents: agentTargets } });
|
|
3738
|
+
return [];
|
|
3739
|
+
}
|
|
3740
|
+
const validTargets = new Set(["codex", "cursor", "copilot", "claude", "gemini", "llms"]);
|
|
3741
|
+
const targets = [];
|
|
3742
|
+
for (const target of rawTargets) {
|
|
3743
|
+
if (!validTargets.has(target)) {
|
|
3744
|
+
throw new CliArgumentError(`Unsupported --agents target: ${target}`, { usage: commandUsage("init"), options: { agents: agentTargets } });
|
|
3745
|
+
}
|
|
3746
|
+
if (!targets.includes(target))
|
|
3747
|
+
targets.push(target);
|
|
3748
|
+
}
|
|
3749
|
+
return targets;
|
|
3750
|
+
}
|
|
3751
|
+
function print(value, json, message) {
|
|
3752
|
+
if (json) {
|
|
3753
|
+
console.log(JSON.stringify(value, null, 2));
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
if (message) {
|
|
3757
|
+
console.log(message);
|
|
3758
|
+
return;
|
|
3759
|
+
}
|
|
3760
|
+
console.log(JSON.stringify(value, null, 2));
|
|
3761
|
+
}
|
|
3762
|
+
function formatInitMessage(result) {
|
|
3763
|
+
if (!result.dryRun) {
|
|
3764
|
+
const lines2 = [`Barry Cache init ${result.changed ? "changed files" : "already up to date"}.`];
|
|
3765
|
+
addInstallHint(lines2, result, false);
|
|
3766
|
+
return lines2.join(`
|
|
3767
|
+
`);
|
|
3768
|
+
}
|
|
3769
|
+
if (!result.changed) {
|
|
3770
|
+
const lines2 = ["Barry Cache init would not change files."];
|
|
3771
|
+
addInstallHint(lines2, result, true);
|
|
3772
|
+
return lines2.join(`
|
|
3773
|
+
`);
|
|
3774
|
+
}
|
|
3775
|
+
const lines = ["Barry Cache init would change files."];
|
|
3776
|
+
addPathSection(lines, "Create:", result.written);
|
|
3777
|
+
addPathSection(lines, "Update:", result.updated);
|
|
3778
|
+
addInstallHint(lines, result, true);
|
|
3779
|
+
return `${lines.join(`
|
|
3780
|
+
`)}
|
|
3781
|
+
`;
|
|
3782
|
+
}
|
|
3783
|
+
function addPathSection(lines, title, paths) {
|
|
3784
|
+
if (paths.length === 0)
|
|
3785
|
+
return;
|
|
3786
|
+
lines.push("", title);
|
|
3787
|
+
for (const path of paths)
|
|
3788
|
+
lines.push(` ${path}`);
|
|
3789
|
+
}
|
|
3790
|
+
function addInstallHint(lines, result, dryRun) {
|
|
3791
|
+
if (!result.packageManager)
|
|
3792
|
+
return;
|
|
3793
|
+
lines.push("", green(`${dryRun ? "After applying, run" : "Run"}: ${result.packageManager.installCommand}`));
|
|
3794
|
+
}
|
|
3795
|
+
function green(value) {
|
|
3796
|
+
return `\x1B[32m${value}\x1B[0m`;
|
|
3797
|
+
}
|
|
3798
|
+
function formatJsonError(error) {
|
|
3799
|
+
const payload = { ok: false, error: errorMessage(error) };
|
|
3800
|
+
if (error instanceof CliArgumentError) {
|
|
3801
|
+
if (error.usage)
|
|
3802
|
+
payload.usage = error.usage;
|
|
3803
|
+
if (error.options)
|
|
3804
|
+
payload.options = error.options;
|
|
3805
|
+
}
|
|
3806
|
+
return payload;
|
|
3807
|
+
}
|
|
3808
|
+
function formatCliError(error) {
|
|
3809
|
+
if (!(error instanceof CliArgumentError))
|
|
3810
|
+
return errorMessage(error);
|
|
3811
|
+
const lines = [error.message];
|
|
3812
|
+
if (error.usage)
|
|
3813
|
+
lines.push("", "Usage:", ` ${error.usage}`);
|
|
3814
|
+
if (error.options) {
|
|
3815
|
+
for (const [name, values2] of Object.entries(error.options)) {
|
|
3816
|
+
lines.push("", `Available --${name} values:`);
|
|
3817
|
+
for (const value of values2)
|
|
3818
|
+
lines.push(` ${value}`);
|
|
3819
|
+
}
|
|
3820
|
+
}
|
|
3821
|
+
return lines.join(`
|
|
3822
|
+
`);
|
|
3823
|
+
}
|
|
3824
|
+
function errorMessage(error) {
|
|
3825
|
+
return error instanceof Error ? error.message : String(error);
|
|
3826
|
+
}
|
|
3827
|
+
function commandUsage(command) {
|
|
3828
|
+
const usages = {
|
|
3829
|
+
init: "barry-cache init [--yes] [--dry-run] [--agents all|none|codex,cursor,copilot,claude,gemini,llms]",
|
|
3830
|
+
route: 'barry-cache route --task "..." [--json]',
|
|
3831
|
+
search: 'barry-cache search --query "..." [--json]',
|
|
3832
|
+
load: 'barry-cache load --route "..." [--json]',
|
|
3833
|
+
resume: 'barry-cache resume --task "..." [--json]',
|
|
3834
|
+
finalize: 'barry-cache finalize --summary "..." [--status success|partial|blocked|failed] [--json]',
|
|
3835
|
+
import: "barry-cache import --source pulpcut-kb --from /path/to/repo [--dry-run] [--json]",
|
|
3836
|
+
review: "barry-cache review [--port 8787] [--open] [--json]"
|
|
3837
|
+
};
|
|
3838
|
+
return usages[command];
|
|
3839
|
+
}
|
|
3840
|
+
function isImportSource(value) {
|
|
3841
|
+
return importSources.includes(value);
|
|
3842
|
+
}
|
|
3843
|
+
function usageText(message) {
|
|
3844
|
+
return `${message ? `${message}
|
|
3845
|
+
|
|
3846
|
+
` : ""}Barry Cache remembers your repo.
|
|
3847
|
+
|
|
3848
|
+
Usage:
|
|
3849
|
+
barry-cache init [--yes] [--dry-run] [--agents all|none|codex,cursor,copilot,claude,gemini,llms]
|
|
3850
|
+
barry-cache validate [--json]
|
|
3851
|
+
barry-cache route --task "..." [--json]
|
|
3852
|
+
barry-cache search --query "..." [--json]
|
|
3853
|
+
barry-cache load --route "..." [--json]
|
|
3854
|
+
barry-cache resume --task "..." [--json]
|
|
3855
|
+
barry-cache finalize --summary "..." [--status success] [--json]
|
|
3856
|
+
barry-cache import --source pulpcut-kb --from /path/to/repo [--dry-run] [--json]
|
|
3857
|
+
barry-cache review [--port 8787] [--open]
|
|
3858
|
+
barry-cache review --json
|
|
3859
|
+
`;
|
|
3860
|
+
}
|
|
3861
|
+
if (__require.main == __require.module) {
|
|
3862
|
+
await main();
|
|
3863
|
+
}
|