@mat3ra/esse 2026.8.18-2 → 2026.8.25-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,420 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ISOLATED_NODE_BASELINE = void 0;
7
+ exports.schemaIdToPublishedPath = schemaIdToPublishedPath;
8
+ exports.publishedPathToSchemaId = publishedPathToSchemaId;
9
+ exports.buildPublishedPathIndex = buildPublishedPathIndex;
10
+ exports.sourcePathToSchemaId = sourcePathToSchemaId;
11
+ exports.classifyLayer = classifyLayer;
12
+ exports.resolveJsonPointer = resolveJsonPointer;
13
+ exports.collectReferences = collectReferences;
14
+ exports.buildEntityGraph = buildEntityGraph;
15
+ exports.validateEntityGraph = validateEntityGraph;
16
+ exports.writeEntityGraph = writeEntityGraph;
17
+ /**
18
+ * Extracts the schema reference graph from the ESSE sources.
19
+ *
20
+ * The graph records how schemas relate to one another: which schemas extend which
21
+ * (`allOf`), which contain which (`properties`/`items`), and which are variants of a
22
+ * union (`oneOf`/`anyOf`). It is emitted as a single `graph.json` asset for the site,
23
+ * and doubles as a lint over the schema corpus.
24
+ *
25
+ * IMPORTANT: this reads the *source* schemas, never the resolved ones under `dist`.
26
+ * Resolution inlines `$ref`s and merges `allOf`, which destroys exactly the structure
27
+ * recorded here.
28
+ */
29
+ const fs_1 = __importDefault(require("fs"));
30
+ const js_yaml_1 = __importDefault(require("js-yaml"));
31
+ const path_1 = __importDefault(require("path"));
32
+ const settings_1 = require("../esse/settings");
33
+ const ajv_1 = require("../utils/ajv");
34
+ const filesystem_1 = require("../utils/filesystem");
35
+ const entity_graph_schema_json_1 = __importDefault(require("./entity_graph.schema.json"));
36
+ const entityGraphLayout_1 = require("./entityGraphLayout");
37
+ /**
38
+ * Isolated schemas at the time the lint baseline was taken; growth is reported, not failed.
39
+ * These are leaf definitions and externally-consumed formats that nothing else references.
40
+ */
41
+ exports.ISOLATED_NODE_BASELINE = 34;
42
+ const REPOSITORY_ROOT = path_1.default.resolve(__dirname, "../../../");
43
+ const ENTITY_DOMAINS = [
44
+ "material",
45
+ "model",
46
+ "method",
47
+ "property",
48
+ "workflow",
49
+ "job",
50
+ "software",
51
+ "compute",
52
+ ];
53
+ /**
54
+ * Maps a schema `$id` to the path of its resolved copy on the published site.
55
+ *
56
+ * Exactly invertible by {@link publishedPathToSchemaId}: an `$id` is a path with underscores
57
+ * replaced by dashes, so no `$id` ever contains an underscore and the mapping is injective.
58
+ *
59
+ * What is *not* recoverable is the **source** path, because a source directory may contain a
60
+ * literal dash (`properties_directory/non-scalar`) that the round-trip through `$id` turns into
61
+ * an underscore. Read `node.path` for that.
62
+ */
63
+ function schemaIdToPublishedPath(schemaId) {
64
+ return `schema/${schemaId.replace(/-/g, "_")}.json`;
65
+ }
66
+ /** Recovers a schema `$id` from the path of its published copy. */
67
+ function publishedPathToSchemaId(publishedPath) {
68
+ return publishedPath
69
+ .replace(/^schema\//, "")
70
+ .replace(/\.json$/, "")
71
+ .replace(/_/g, "-");
72
+ }
73
+ /** Builds the published-path to `$id` lookup, for callers that prefer a map to a function. */
74
+ function buildPublishedPathIndex(nodes) {
75
+ return Object.fromEntries(nodes.map((node) => [node.publishedPath, node.id]));
76
+ }
77
+ /** Derives the `$id` a schema at this source path must declare, per `setSchemaIds`. */
78
+ function sourcePathToSchemaId(sourcePath) {
79
+ return path_1.default
80
+ .relative(settings_1.SCHEMAS_DIR, sourcePath)
81
+ .replace(/\.json$/, "")
82
+ .replace(/_/g, "-");
83
+ }
84
+ function schemaIdSegments(schemaId) {
85
+ // Work from the underscored form so that "non-scalar" and "non_scalar" agree.
86
+ return schemaId.replace(/-/g, "_").split("/");
87
+ }
88
+ /**
89
+ * Assigns a layer to every schema. The classification is total by contract: an
90
+ * unclassifiable path is a lint failure, so a newly added top-level directory forces a
91
+ * deliberate decision here instead of silently landing in a catch-all bucket.
92
+ */
93
+ function classifyLayer(schemaId) {
94
+ const segments = schemaIdSegments(schemaId);
95
+ const [first, second] = segments;
96
+ if (first === "core") {
97
+ if (second === "primitive")
98
+ return { layer: "primitive" };
99
+ if (second === "abstract")
100
+ return { layer: "abstract" };
101
+ if (second === "reusable")
102
+ return { layer: "reusable" };
103
+ if (second === "reference")
104
+ return { layer: "reference" };
105
+ return undefined;
106
+ }
107
+ if (first === "definitions")
108
+ return { layer: "definition" };
109
+ if (first === "in_memory_entity")
110
+ return { layer: "in-memory-entity" };
111
+ if (first === "system")
112
+ return { layer: "system" };
113
+ if (segments.length === 1)
114
+ return { layer: "entity" };
115
+ if (ENTITY_DOMAINS.includes(first))
116
+ return { layer: "entity-component", ownerEntity: first };
117
+ if (first.endsWith("_category") || first === "materials_category_components") {
118
+ return { layer: "category" };
119
+ }
120
+ if (first.endsWith("_directory"))
121
+ return { layer: "directory" };
122
+ if (first === "apse")
123
+ return { layer: "application-parsing" };
124
+ return undefined;
125
+ }
126
+ /** Resolves a JSON pointer within a document, returning undefined when it does not exist. */
127
+ function resolveJsonPointer(document, pointer) {
128
+ if (pointer === "" || pointer === "/")
129
+ return document;
130
+ const tokens = pointer
131
+ .replace(/^\//, "")
132
+ .split("/")
133
+ .map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~"));
134
+ return tokens.reduce((current, token) => {
135
+ if (current === null || typeof current !== "object")
136
+ return undefined;
137
+ const container = current;
138
+ return token in container ? container[token] : undefined;
139
+ }, document);
140
+ }
141
+ /**
142
+ * Collects every `$ref` in a schema together with the relationship it expresses.
143
+ *
144
+ * The relationship comes from the innermost enclosing structural keyword: a `$ref` inside
145
+ * `properties.foo.allOf[0]` extends, while one inside `allOf[0].properties.foo` is contained.
146
+ * The enclosing property name travels with the reference regardless of kind, so a union
147
+ * under `properties.data.oneOf` is recorded as a variant *of `data`*.
148
+ */
149
+ function collectReferences(schema) {
150
+ const references = [];
151
+ const walk = (value, context) => {
152
+ if (Array.isArray(value)) {
153
+ value.forEach((item) => walk(item, context));
154
+ return;
155
+ }
156
+ if (value === null || typeof value !== "object")
157
+ return;
158
+ const node = value;
159
+ if (typeof node.$ref === "string") {
160
+ references.push({ ref: node.$ref, kind: context.kind, label: context.label });
161
+ }
162
+ Object.entries(node).forEach(([key, child]) => {
163
+ if (key === "$ref")
164
+ return;
165
+ if (key === "allOf") {
166
+ walk(child, { kind: "extends", label: context.label });
167
+ }
168
+ else if (key === "anyOf" || key === "oneOf") {
169
+ walk(child, { kind: "variant", label: context.label });
170
+ }
171
+ else if (key === "properties" && child !== null && typeof child === "object") {
172
+ Object.entries(child).forEach(([propertyName, propertySchema]) => {
173
+ walk(propertySchema, { kind: "contains", label: propertyName });
174
+ });
175
+ }
176
+ else if (key === "items") {
177
+ walk(child, {
178
+ kind: "contains",
179
+ label: context.label ? `${context.label}[]` : "[]",
180
+ });
181
+ }
182
+ else {
183
+ walk(child, context);
184
+ }
185
+ });
186
+ };
187
+ // No structural keyword yet, so no relationship yet. Every `$ref` in the corpus today
188
+ // passes through one before it is reached; a future one that does not gets `kind`
189
+ // undefined and fails L4, which is the point — the classification stays deliberate
190
+ // rather than defaulting to `contains` and quietly mislabelling the edge.
191
+ walk(schema, {});
192
+ return references;
193
+ }
194
+ function loadSchemas() {
195
+ const loaded = [];
196
+ (0, filesystem_1.walkDirSync)(settings_1.SCHEMAS_DIR, (filePath) => {
197
+ if (!filePath.endsWith(".json"))
198
+ return;
199
+ loaded.push({
200
+ sourcePath: filePath,
201
+ relativePath: path_1.default.relative(REPOSITORY_ROOT, filePath),
202
+ schema: JSON.parse(fs_1.default.readFileSync(filePath, "utf-8")),
203
+ });
204
+ });
205
+ return loaded.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
206
+ }
207
+ function readPropertiesManifest() {
208
+ var _a;
209
+ if (!fs_1.default.existsSync(settings_1.PROPERTIES_MANIFEST_PATH))
210
+ return {};
211
+ return ((_a = js_yaml_1.default.load(fs_1.default.readFileSync(settings_1.PROPERTIES_MANIFEST_PATH, "utf-8"))) !== null && _a !== void 0 ? _a : {});
212
+ }
213
+ function buildEntityGraph() {
214
+ const failures = [];
215
+ const warnings = [];
216
+ const loadedSchemas = loadSchemas();
217
+ const schemasBySourcePath = new Map(loadedSchemas.map((item) => [item.sourcePath, item]));
218
+ const nodes = new Map();
219
+ const nodeIdBySourcePath = new Map();
220
+ loadedSchemas.forEach(({ sourcePath, relativePath, schema }) => {
221
+ var _a, _b;
222
+ const expectedId = sourcePathToSchemaId(sourcePath);
223
+ const declaredId = typeof schema.$id === "string" ? schema.$id : undefined;
224
+ if (declaredId !== expectedId) {
225
+ failures.push(`L2 ${relativePath}: $id is ${JSON.stringify(declaredId)}, expected ${JSON.stringify(expectedId)} (run "npm run set-schema-ids")`);
226
+ }
227
+ const id = declaredId !== null && declaredId !== void 0 ? declaredId : expectedId;
228
+ const classification = classifyLayer(id);
229
+ if (!classification) {
230
+ failures.push(`L3 ${relativePath}: no layer rule matches this path — add one to classifyLayer`);
231
+ }
232
+ const relativeToSchemas = path_1.default.relative(settings_1.SCHEMAS_DIR, sourcePath);
233
+ const domain = relativeToSchemas.includes(path_1.default.sep)
234
+ ? relativeToSchemas.split(path_1.default.sep)[0]
235
+ : "(root)";
236
+ const properties = ((_a = schema.properties) !== null && _a !== void 0 ? _a : {});
237
+ nodes.set(id, {
238
+ id,
239
+ path: relativePath,
240
+ publishedPath: schemaIdToPublishedPath(id),
241
+ title: typeof schema.title === "string" ? schema.title : "",
242
+ description: typeof schema.description === "string" ? schema.description : "",
243
+ domain,
244
+ layer: (_b = classification === null || classification === void 0 ? void 0 : classification.layer) !== null && _b !== void 0 ? _b : "entity",
245
+ ...((classification === null || classification === void 0 ? void 0 : classification.ownerEntity) ? { ownerEntity: classification.ownerEntity } : {}),
246
+ inDegree: 0,
247
+ outDegree: 0,
248
+ propertyCount: Object.keys(properties).length,
249
+ hasExample: fs_1.default.existsSync(path_1.default.join(settings_1.EXAMPLES_DIR, relativeToSchemas)),
250
+ });
251
+ nodeIdBySourcePath.set(sourcePath, id);
252
+ });
253
+ const edges = [];
254
+ let sameDocumentRefCount = 0;
255
+ loadedSchemas.forEach(({ sourcePath, relativePath, schema }) => {
256
+ const sourceId = nodeIdBySourcePath.get(sourcePath);
257
+ collectReferences(schema).forEach(({ ref, kind, label }) => {
258
+ var _a;
259
+ const [filePart, pointerPart] = ref.split("#");
260
+ const pointer = pointerPart ? `/${pointerPart.replace(/^\//, "")}` : undefined;
261
+ if (!filePart) {
262
+ // Same-document reference: valid, but not an edge between schemas.
263
+ sameDocumentRefCount += 1;
264
+ if (pointer && resolveJsonPointer(schema, pointer) === undefined) {
265
+ failures.push(`L5 ${relativePath}: pointer ${pointer} does not exist in itself`);
266
+ }
267
+ return;
268
+ }
269
+ const targetPath = path_1.default.resolve(path_1.default.dirname(sourcePath), filePart);
270
+ const targetId = nodeIdBySourcePath.get(targetPath);
271
+ if (!targetId) {
272
+ failures.push(`L1 ${relativePath}: $ref "${ref}" does not resolve to a schema`);
273
+ return;
274
+ }
275
+ if (!kind) {
276
+ failures.push(`L4 ${relativePath}: $ref "${ref}" is not inside allOf, oneOf, anyOf, ` +
277
+ `properties or items, so its relationship kind is undecided`);
278
+ return;
279
+ }
280
+ if (pointer) {
281
+ const targetSchema = (_a = schemasBySourcePath.get(targetPath)) === null || _a === void 0 ? void 0 : _a.schema;
282
+ if (resolveJsonPointer(targetSchema, pointer) === undefined) {
283
+ failures.push(`L5 ${relativePath}: $ref "${ref}" points at ${pointer}, which does not exist in the target`);
284
+ }
285
+ }
286
+ edges.push({
287
+ source: sourceId,
288
+ target: targetId,
289
+ kind,
290
+ ...(label ? { label } : {}),
291
+ ...(pointer ? { pointer } : {}),
292
+ });
293
+ });
294
+ });
295
+ edges.forEach((edge) => {
296
+ const source = nodes.get(edge.source);
297
+ const target = nodes.get(edge.target);
298
+ if (source)
299
+ source.outDegree += 1;
300
+ if (target)
301
+ target.inDegree += 1;
302
+ });
303
+ // L7 — reference cycles. The README forbids them; keep it true as schemas are added.
304
+ const adjacency = new Map();
305
+ edges.forEach((edge) => {
306
+ if (!adjacency.has(edge.source))
307
+ adjacency.set(edge.source, new Set());
308
+ adjacency.get(edge.source).add(edge.target);
309
+ });
310
+ const VISITING = 1;
311
+ const VISITED = 2;
312
+ const state = new Map();
313
+ const trail = [];
314
+ const visit = (id) => {
315
+ var _a;
316
+ state.set(id, VISITING);
317
+ trail.push(id);
318
+ [...((_a = adjacency.get(id)) !== null && _a !== void 0 ? _a : [])].sort().forEach((neighbour) => {
319
+ if (state.get(neighbour) === VISITING) {
320
+ const cycleStart = trail.indexOf(neighbour);
321
+ failures.push(`L7 cycle: ${[...trail.slice(cycleStart), neighbour].join(" -> ")}`);
322
+ }
323
+ else if (state.get(neighbour) === undefined) {
324
+ visit(neighbour);
325
+ }
326
+ });
327
+ trail.pop();
328
+ state.set(id, VISITED);
329
+ };
330
+ [...nodes.keys()].sort().forEach((id) => {
331
+ if (state.get(id) === undefined)
332
+ visit(id);
333
+ });
334
+ // L6 — every manifest entry must point at a schema that exists.
335
+ const propertiesManifest = readPropertiesManifest();
336
+ Object.entries(propertiesManifest).forEach(([name, entry]) => {
337
+ var _a;
338
+ const schemaId = entry === null || entry === void 0 ? void 0 : entry.schemaId;
339
+ if (typeof schemaId !== "string")
340
+ return;
341
+ const node = nodes.get(schemaId);
342
+ if (!node) {
343
+ failures.push(`L6 manifest/properties.yaml: "${name}" references schemaId "${schemaId}", which does not exist`);
344
+ return;
345
+ }
346
+ node.manifest = {
347
+ name,
348
+ ...(entry.isResult ? { isResult: true } : {}),
349
+ ...(entry.isMonitor ? { isMonitor: true } : {}),
350
+ ...(((_a = entry.defaults) === null || _a === void 0 ? void 0 : _a.units) ? { defaultUnits: String(entry.defaults.units) } : {}),
351
+ };
352
+ });
353
+ const sortedNodes = (0, entityGraphLayout_1.computeEntityGraphLayout)([...nodes.values()].sort((a, b) => a.id.localeCompare(b.id)));
354
+ const sortedEdges = edges.sort((a, b) => {
355
+ var _a, _b, _c, _d;
356
+ return a.source.localeCompare(b.source) ||
357
+ a.target.localeCompare(b.target) ||
358
+ a.kind.localeCompare(b.kind) ||
359
+ ((_a = a.label) !== null && _a !== void 0 ? _a : "").localeCompare((_b = b.label) !== null && _b !== void 0 ? _b : "") ||
360
+ ((_c = a.pointer) !== null && _c !== void 0 ? _c : "").localeCompare((_d = b.pointer) !== null && _d !== void 0 ? _d : "");
361
+ });
362
+ const isolatedNodes = sortedNodes.filter((node) => !node.inDegree && !node.outDegree);
363
+ if (isolatedNodes.length > exports.ISOLATED_NODE_BASELINE) {
364
+ warnings.push(`L8 ${isolatedNodes.length} isolated schemas, up from a baseline of ${exports.ISOLATED_NODE_BASELINE}: ` +
365
+ `${isolatedNodes.map((node) => node.id).join(", ")}`);
366
+ }
367
+ const schemasWithExample = sortedNodes.filter((node) => node.hasExample).length;
368
+ warnings.push(`L9 example coverage: ${schemasWithExample}/${sortedNodes.length} schemas have an example ` +
369
+ `(${Math.round((schemasWithExample / sortedNodes.length) * 100)}%)`);
370
+ const edgeCountsByKind = {
371
+ extends: 0,
372
+ contains: 0,
373
+ variant: 0,
374
+ };
375
+ sortedEdges.forEach((edge) => {
376
+ edgeCountsByKind[edge.kind] += 1;
377
+ });
378
+ const layerCounts = {};
379
+ sortedNodes.forEach((node) => {
380
+ var _a;
381
+ layerCounts[node.layer] = ((_a = layerCounts[node.layer]) !== null && _a !== void 0 ? _a : 0) + 1;
382
+ });
383
+ return {
384
+ graph: {
385
+ meta: {
386
+ nodeCount: sortedNodes.length,
387
+ edgeCount: sortedEdges.length,
388
+ edgeCountsByKind,
389
+ layerCounts: Object.fromEntries(Object.entries(layerCounts).sort()),
390
+ sameDocumentRefCount,
391
+ schemasWithExample,
392
+ isolatedNodeCount: isolatedNodes.length,
393
+ },
394
+ nodes: sortedNodes,
395
+ edges: sortedEdges,
396
+ },
397
+ lint: { failures, warnings },
398
+ };
399
+ }
400
+ /**
401
+ * L10 — validates the emitted graph against its schema.
402
+ *
403
+ * That schema lives here rather than in `schema/`: it describes a build artifact, not an
404
+ * entity of digital materials science, so it has no business in the corpus this tool
405
+ * measures. Imported rather than read from disk so it resolves identically whether the
406
+ * module runs from source or from the transpiled copy in `dist/`.
407
+ */
408
+ function validateEntityGraph(graph) {
409
+ const { isValid, errors } = (0, ajv_1.validate)(graph, entity_graph_schema_json_1.default);
410
+ if (isValid)
411
+ return [];
412
+ return (errors !== null && errors !== void 0 ? errors : []).map((error) => { var _a; return `L10 graph.json${error.instancePath} ${(_a = error.message) !== null && _a !== void 0 ? _a : "is invalid"}`; });
413
+ }
414
+ /** Writes `graph.json` into the given directory, creating it when missing. */
415
+ function writeEntityGraph(graph, outputDir) {
416
+ fs_1.default.mkdirSync(outputDir, { recursive: true });
417
+ const outputPath = path_1.default.join(outputDir, "graph.json");
418
+ fs_1.default.writeFileSync(outputPath, `${JSON.stringify(graph, null, 4)}\n`, "utf8");
419
+ return outputPath;
420
+ }
@@ -0,0 +1,6 @@
1
+ export interface SiteLinkProblem {
2
+ page: string;
3
+ href: string;
4
+ reason: string;
5
+ }
6
+ export declare function checkSiteLinks(siteDir: string): SiteLinkProblem[];
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.checkSiteLinks = checkSiteLinks;
7
+ /**
8
+ * Verifies that every internal link in the assembled site resolves.
9
+ *
10
+ * The site is stitched together from four sources — resolved schemas, the concept docs, the
11
+ * explorer and the Entity Map — each of which links into the others. Nothing else checks that
12
+ * those links land anywhere, and a stale href is invisible until someone clicks it.
13
+ *
14
+ * External URLs are not fetched; this is a build step, not a crawler.
15
+ */
16
+ const fs_1 = __importDefault(require("fs"));
17
+ const path_1 = __importDefault(require("path"));
18
+ const HREF_PATTERN = /(?:href|src)\s*=\s*"([^"]+)"/g;
19
+ function listFiles(directory, extension) {
20
+ if (!fs_1.default.existsSync(directory))
21
+ return [];
22
+ return fs_1.default.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
23
+ const entryPath = path_1.default.join(directory, entry.name);
24
+ if (entry.isDirectory())
25
+ return listFiles(entryPath, extension);
26
+ return entry.name.endsWith(extension) ? [entryPath] : [];
27
+ });
28
+ }
29
+ /** True for anything this build step cannot or should not resolve on disk. */
30
+ function isExternal(href) {
31
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("data:");
32
+ }
33
+ function checkSiteLinks(siteDir) {
34
+ const problems = [];
35
+ const pages = listFiles(siteDir, ".html");
36
+ if (pages.length === 0) {
37
+ return [{ page: siteDir, href: "", reason: "no HTML pages found — was the site built?" }];
38
+ }
39
+ pages.forEach((page) => {
40
+ const html = fs_1.default.readFileSync(page, "utf-8");
41
+ const seen = new Set();
42
+ Array.from(html.matchAll(HREF_PATTERN)).forEach((match) => {
43
+ const href = match[1];
44
+ if (seen.has(href))
45
+ return;
46
+ seen.add(href);
47
+ if (isExternal(href) || href.startsWith("#") || href.trim() === "")
48
+ return;
49
+ // Only the path part is checked; in-page fragments and the map's hash routes are
50
+ // resolved by the browser, not the filesystem.
51
+ const [pathPart] = href.split("#");
52
+ if (!pathPart)
53
+ return;
54
+ const target = path_1.default.resolve(path_1.default.dirname(page), pathPart);
55
+ const relativePage = path_1.default.relative(siteDir, page);
56
+ if (!target.startsWith(path_1.default.resolve(siteDir))) {
57
+ problems.push({
58
+ page: relativePage,
59
+ href,
60
+ reason: "escapes the site directory",
61
+ });
62
+ return;
63
+ }
64
+ const resolved = fs_1.default.existsSync(target) && fs_1.default.statSync(target).isDirectory()
65
+ ? path_1.default.join(target, "index.html")
66
+ : target;
67
+ if (!fs_1.default.existsSync(resolved)) {
68
+ problems.push({ page: relativePage, href, reason: "target does not exist" });
69
+ }
70
+ });
71
+ });
72
+ return problems;
73
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Computes map coordinates for the entity graph.
3
+ *
4
+ * The layout is deliberately *not* a force simulation. A force layout arranges schemas by
5
+ * how densely they reference one another, which reproduces the directory tree and teaches a
6
+ * reader nothing they could not get from `ls -R`. This places schemas by architectural
7
+ * layer instead: the primitives everything is built from sit at the centre, the root
8
+ * entities form a ring around them, and the category/directory catalogues sit on the rim.
9
+ * Reading outward from the middle is reading the build-up the concept docs describe.
10
+ *
11
+ * Being a pure function of the graph, it is also deterministic: unchanged schemas keep their
12
+ * coordinates from one release to the next, so the map is worth building spatial memory of.
13
+ */
14
+ import type { EntityGraphNode } from "./buildEntityGraph";
15
+ /**
16
+ * Assigns `x`/`y` to every node in place and returns the same array.
17
+ *
18
+ * Nodes are grouped into bands by layer, then ordered within a band by domain so that
19
+ * schemas from the same corner of the corpus land next to one another on the ring.
20
+ */
21
+ export declare function computeEntityGraphLayout(nodes: EntityGraphNode[]): EntityGraphNode[];
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeEntityGraphLayout = computeEntityGraphLayout;
4
+ /** Layers ordered from the centre outward, following the dependency direction. */
5
+ const LAYER_BANDS = [
6
+ ["definition", "primitive"],
7
+ ["abstract"],
8
+ ["reusable", "reference"],
9
+ ["in-memory-entity", "system"],
10
+ ["entity"],
11
+ ["entity-component"],
12
+ ["category"],
13
+ ["directory", "application-parsing"],
14
+ ];
15
+ /** Minimum arc length between neighbouring nodes on a ring, in graph units. */
16
+ const NODE_SPACING = 62;
17
+ /** Minimum radial distance between consecutive bands. */
18
+ const BAND_GAP = 190;
19
+ /** Radial wobble as a fraction of BAND_GAP, so bands read as belts rather than wireframe circles. */
20
+ const JITTER_FRACTION = 0.16;
21
+ /** Stable hash used for deterministic jitter; identical input always yields identical output. */
22
+ function hashToUnitInterval(value) {
23
+ let hash = 2166136261;
24
+ for (let index = 0; index < value.length; index += 1) {
25
+ // eslint-disable-next-line no-bitwise
26
+ hash = Math.imul(hash ^ value.charCodeAt(index), 16777619);
27
+ }
28
+ // eslint-disable-next-line no-bitwise
29
+ return ((hash >>> 0) % 10000) / 10000;
30
+ }
31
+ /**
32
+ * Assigns `x`/`y` to every node in place and returns the same array.
33
+ *
34
+ * Nodes are grouped into bands by layer, then ordered within a band by domain so that
35
+ * schemas from the same corner of the corpus land next to one another on the ring.
36
+ */
37
+ function computeEntityGraphLayout(nodes) {
38
+ const nodesByLayer = new Map();
39
+ nodes.forEach((node) => {
40
+ if (!nodesByLayer.has(node.layer))
41
+ nodesByLayer.set(node.layer, []);
42
+ nodesByLayer.get(node.layer).push(node);
43
+ });
44
+ let previousRadius = 0;
45
+ LAYER_BANDS.forEach((layers) => {
46
+ const band = layers
47
+ .flatMap((layer) => { var _a; return (_a = nodesByLayer.get(layer)) !== null && _a !== void 0 ? _a : []; })
48
+ .sort((a, b) => {
49
+ var _a, _b;
50
+ return a.domain.localeCompare(b.domain) ||
51
+ ((_a = a.ownerEntity) !== null && _a !== void 0 ? _a : "").localeCompare((_b = b.ownerEntity) !== null && _b !== void 0 ? _b : "") ||
52
+ a.id.localeCompare(b.id);
53
+ });
54
+ if (band.length === 0)
55
+ return;
56
+ // Grow the ring until neighbours are at least NODE_SPACING apart along the arc.
57
+ const circumferenceRadius = (band.length * NODE_SPACING) / (2 * Math.PI);
58
+ const radius = Math.max(previousRadius + BAND_GAP, circumferenceRadius);
59
+ band.forEach((node, index) => {
60
+ const angle = (2 * Math.PI * index) / band.length;
61
+ const jitter = (hashToUnitInterval(node.id) - 0.5) * BAND_GAP * JITTER_FRACTION;
62
+ const effectiveRadius = radius + jitter;
63
+ node.x = Math.round(Math.cos(angle) * effectiveRadius * 100) / 100;
64
+ node.y = Math.round(Math.sin(angle) * effectiveRadius * 100) / 100;
65
+ });
66
+ previousRadius = radius;
67
+ });
68
+ // Any layer not named in LAYER_BANDS would be left without coordinates; the lint's
69
+ // total-classification rule prevents that, but fail loudly rather than render at 0,0.
70
+ const unplaced = nodes.filter((node) => node.x === undefined || node.y === undefined);
71
+ if (unplaced.length > 0) {
72
+ throw new Error(`Layout missed ${unplaced.length} node(s); add their layer to LAYER_BANDS: ` +
73
+ `${unplaced.map((node) => node.layer).join(", ")}`);
74
+ }
75
+ return nodes;
76
+ }