@synap-core/workspace-templates 0.7.0 → 0.7.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/dist/index.d.ts +172 -1
- package/dist/index.js +370 -28
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
- package/src/business-developer.yaml +8 -0
- package/src/catalog.test.ts +2 -2
- package/src/composition-graph.test.ts +135 -0
- package/src/composition-graph.ts +298 -0
- package/src/concept-guard.test.ts +176 -0
- package/src/concept-guard.ts +219 -0
- package/src/ecosystem.yaml +4 -1
- package/src/generate.d.mts +15 -0
- package/src/generate.mjs +86 -0
- package/src/index.ts +24 -0
- package/src/meetings-knowledge.yaml +136 -22
- package/src/outreach-comms.yaml +5 -2
- package/src/templates-freshness.test.ts +55 -0
- package/src/templates.ts +126 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synap-core/workspace-templates",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "pnpm@9.15.4",
|
|
6
6
|
"description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
|
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
"type-check": "tsc --noEmit",
|
|
20
20
|
"build:yaml": "node build.mjs",
|
|
21
21
|
"check:converters": "node scripts/check-converters.mjs",
|
|
22
|
-
"
|
|
22
|
+
"check:concepts": "node scripts/check-concepts.mjs",
|
|
23
|
+
"build": "node build.mjs && tsup && node scripts/check-converters.mjs && node scripts/check-concepts.mjs",
|
|
23
24
|
"provision": "node scripts/provision.mjs",
|
|
24
25
|
"eval": "node scripts/eval-standard.mjs",
|
|
25
|
-
"prepublishOnly": "node build.mjs && tsup && node scripts/check-converters.mjs"
|
|
26
|
+
"prepublishOnly": "node build.mjs && tsup && node scripts/check-converters.mjs && node scripts/check-concepts.mjs"
|
|
26
27
|
},
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"zod": "^3.0.0",
|
|
@@ -50,6 +50,12 @@ profiles:
|
|
|
50
50
|
description: A person or company actively being qualified or contacted by this CRM workspace.
|
|
51
51
|
scope: WORKSPACE
|
|
52
52
|
profileKind: role
|
|
53
|
+
# Explicit concept identity: this is the WORKSPACE tier of the `lead`
|
|
54
|
+
# concept, not a second, unrelated one. Declaring it makes the two-tier
|
|
55
|
+
# design machine-readable (both tiers are reachable via getBySemanticSlug)
|
|
56
|
+
# and is what the cross-template concept guard requires alongside its
|
|
57
|
+
# DECLARED_DIVERGENCES entry — see src/concept-guard.ts.
|
|
58
|
+
semanticSlug: crm.lead
|
|
53
59
|
applicableKinds: [person, company]
|
|
54
60
|
properties:
|
|
55
61
|
- slug: lifecycleStage
|
|
@@ -112,6 +118,8 @@ profiles:
|
|
|
112
118
|
description: A CRM lead converted by a won commercial proposal and ready for handoff.
|
|
113
119
|
scope: WORKSPACE
|
|
114
120
|
profileKind: role
|
|
121
|
+
# Workspace tier of the `client` concept — see the note on `crm-lead` above.
|
|
122
|
+
semanticSlug: crm.client
|
|
115
123
|
applicableKinds: [person, company]
|
|
116
124
|
properties:
|
|
117
125
|
- slug: becameClientAt
|
package/src/catalog.test.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for the mergeCatalog door. Run: `npx
|
|
2
|
+
* Unit tests for the mergeCatalog door. Run: `npx vitest run packages/workspace-templates` (from the repo root).
|
|
3
3
|
*
|
|
4
4
|
* These test the SOURCE (`./catalog.js` → catalog.ts), never `dist/`, so no build
|
|
5
5
|
* is required and there is no stale-generated-output trap.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { test } from "
|
|
8
|
+
import { test } from "vitest";
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import { mergeCatalog, computeTemplateUpdates, type RemoteEntry } from "./catalog.js";
|
|
11
11
|
import type { WorkspaceYaml } from "./types.js";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the `layerTemplateGraph` KEYSTONE engine.
|
|
3
|
+
* Run: `npx vitest run packages/workspace-templates` (from the repo root).
|
|
4
|
+
*
|
|
5
|
+
* These test the SOURCE (`./composition-graph.js` → composition-graph.ts),
|
|
6
|
+
* never `dist/`, so no build is required and there is no stale-generated-
|
|
7
|
+
* output trap (same convention as `catalog.test.ts`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test } from "vitest";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { layerTemplateGraph, type CompositionGraph } from "./composition-graph.js";
|
|
13
|
+
import type { TemplateDependency } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** Build a small `edgesOf` from a plain adjacency map, for test fixtures. */
|
|
16
|
+
function edgesFrom(map: Record<string, TemplateDependency[]>) {
|
|
17
|
+
return (slug: string): readonly TemplateDependency[] => map[slug] ?? [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function flat(graph: CompositionGraph): string[] {
|
|
21
|
+
return graph.layers.flat().map((n) => n.slug);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Longest-path: deeper occurrence wins (bedrock behavior) ────────────────
|
|
25
|
+
test("longest-path: a node required at depth 1 by one root and depth 3 by another lands at the deeper layer", () => {
|
|
26
|
+
// root-a -> shared (depth 1)
|
|
27
|
+
// root-b -> mid -> deep -> shared (depth 3)
|
|
28
|
+
const edgesOf = edgesFrom({
|
|
29
|
+
"root-a": [{ slug: "shared" }],
|
|
30
|
+
"root-b": [{ slug: "mid" }],
|
|
31
|
+
mid: [{ slug: "deep" }],
|
|
32
|
+
deep: [{ slug: "shared" }],
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const graph = layerTemplateGraph({ roots: ["root-a", "root-b"], edgesOf });
|
|
36
|
+
const shared = graph.nodes.get("shared")!;
|
|
37
|
+
assert.equal(shared.layer, 3, "shared must sink to the DEEPER of its two reachable depths");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── Dedupe / fan-in ──────────────────────────────────────────────────────────
|
|
41
|
+
test("dedupe/fan-in: a node reachable from 6 parents appears ONCE with dependents.length === 6", () => {
|
|
42
|
+
const parents = Array.from({ length: 6 }, (_, i) => `parent-${i}`);
|
|
43
|
+
const edgesOf = edgesFrom({
|
|
44
|
+
root: parents.map((p) => ({ slug: p })),
|
|
45
|
+
...Object.fromEntries(parents.map((p) => [p, [{ slug: "foundation" }]])),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const graph = layerTemplateGraph({ roots: ["root"], edgesOf });
|
|
49
|
+
const foundationNodes = [...graph.nodes.values()].filter((n) => n.slug === "foundation");
|
|
50
|
+
assert.equal(foundationNodes.length, 1, "foundation must appear exactly once in the node map");
|
|
51
|
+
assert.equal(foundationNodes[0]!.dependents.length, 6, "foundation must carry TRUE fan-in from all 6 parents");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── Multi-root ───────────────────────────────────────────────────────────────
|
|
55
|
+
test("multi-root: two roots both reaching a shared node → roots lists both", () => {
|
|
56
|
+
const edgesOf = edgesFrom({
|
|
57
|
+
"root-a": [{ slug: "shared" }],
|
|
58
|
+
"root-b": [{ slug: "shared" }],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const graph = layerTemplateGraph({ roots: ["root-a", "root-b"], edgesOf });
|
|
62
|
+
const shared = graph.nodes.get("shared")!;
|
|
63
|
+
assert.deepEqual(shared.roots, ["root-a", "root-b"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Compose overlays ─────────────────────────────────────────────────────────
|
|
67
|
+
test("compose: a relation:compose dep appears in the base's overlays[] and NOT in layers", () => {
|
|
68
|
+
// `grants` composes onto `operations` — mirrors the real grants.yaml/operations.yaml pair.
|
|
69
|
+
const edgesOf = edgesFrom({
|
|
70
|
+
grants: [{ slug: "operations", relation: "compose", reason: "overlay" }],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const graph = layerTemplateGraph({ roots: ["grants"], edgesOf });
|
|
74
|
+
|
|
75
|
+
const operations = graph.nodes.get("operations")!;
|
|
76
|
+
assert.ok(operations, "operations (the base) must be a real node");
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
operations.overlays.map((o) => o.slug),
|
|
79
|
+
["grants"],
|
|
80
|
+
"the compose source must be attached as an overlay on the base"
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
assert.equal(graph.nodes.has("grants"), false, "the compose SOURCE must never be its own node");
|
|
84
|
+
assert.ok(!flat(graph).includes("grants"), "the compose source must never appear in layers[][]");
|
|
85
|
+
|
|
86
|
+
const composeEdge = graph.edges.find((e) => e.from === "grants" && e.to === "operations");
|
|
87
|
+
assert.ok(composeEdge, "the compose edge must still be recorded in edges[]");
|
|
88
|
+
assert.equal(composeEdge!.relation, "compose");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── Ghost nodes ──────────────────────────────────────────────────────────────
|
|
92
|
+
test("ghost: an uninstalled dep is present with installed:false", () => {
|
|
93
|
+
const edgesOf = edgesFrom({
|
|
94
|
+
root: [{ slug: "not-installed-yet" }],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const graph = layerTemplateGraph({ roots: ["root"], edgesOf, installed: [] });
|
|
98
|
+
const ghost = graph.nodes.get("not-installed-yet")!;
|
|
99
|
+
assert.equal(ghost.installed, false);
|
|
100
|
+
assert.deepEqual(ghost.workspaceIds, []);
|
|
101
|
+
// The gap IS the information: it still occupies its layer.
|
|
102
|
+
assert.equal(graph.layers[ghost.layer]!.some((n) => n.slug === "not-installed-yet"), true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── Cycles ───────────────────────────────────────────────────────────────────
|
|
106
|
+
test("cycle: A→B→A terminates and reports the broken edge in cycles[]", () => {
|
|
107
|
+
const edgesOf = edgesFrom({
|
|
108
|
+
A: [{ slug: "B" }],
|
|
109
|
+
B: [{ slug: "A" }],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const graph = layerTemplateGraph({ roots: ["A"], edgesOf });
|
|
113
|
+
|
|
114
|
+
assert.equal(graph.cycles.length, 1);
|
|
115
|
+
assert.deepEqual(graph.cycles[0], ["B", "A"]);
|
|
116
|
+
|
|
117
|
+
// Still terminates and produces a usable graph.
|
|
118
|
+
assert.ok(graph.nodes.has("A"));
|
|
119
|
+
assert.ok(graph.nodes.has("B"));
|
|
120
|
+
assert.equal(graph.nodes.get("A")!.layer, 0);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── edgesOf injection ─────────────────────────────────────────────────────────
|
|
124
|
+
test("edgesOf injection: deps supplied for a slug NOT in the bundled map are still walked", () => {
|
|
125
|
+
// "private-remote-pkg" does not exist in the bundled WORKSPACE_TEMPLATES map
|
|
126
|
+
// at all — this is the live bug rule 5 fixes: a caller-supplied edgesOf must
|
|
127
|
+
// still be able to walk it.
|
|
128
|
+
const edgesOf = edgesFrom({
|
|
129
|
+
"private-remote-pkg": [{ slug: "some-base", relation: "require" }],
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const graph = layerTemplateGraph({ roots: ["private-remote-pkg"], edgesOf });
|
|
133
|
+
assert.ok(graph.nodes.has("some-base"), "a dependency declared only by the injected edgesOf must still be walked");
|
|
134
|
+
assert.equal(graph.nodes.get("some-base")!.layer, 1);
|
|
135
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* layerTemplateGraph — the KEYSTONE composition-graph engine.
|
|
3
|
+
* ============================================================
|
|
4
|
+
*
|
|
5
|
+
* Today the CLI (`synap-cli/src/commands/market.ts` `buildCompositionTrees`)
|
|
6
|
+
* renders template composition as a nested TREE: `buildDependencyNode`
|
|
7
|
+
* rebuilds a fresh `TreeNode` for every OCCURRENCE of a slug on every path, so
|
|
8
|
+
* a widely-required base (e.g. `foundation`) prints once per branch that
|
|
9
|
+
* reaches it (6× under a suite like `enterprise-os`). Its "shared" tag is only
|
|
10
|
+
* top-level-package granularity — there is no true fan-in count.
|
|
11
|
+
*
|
|
12
|
+
* This module replaces that with a LAYERED DAG: every node appears EXACTLY
|
|
13
|
+
* ONCE, positioned at its longest-path depth, with edges converging on it —
|
|
14
|
+
* so `foundation` sinks to the bedrock row with N inbound edges instead of
|
|
15
|
+
* being redrawn N times. It is the single source of truth for template
|
|
16
|
+
* composition; the CLI, browser dialogs, backend boot-ordering, and (later)
|
|
17
|
+
* the Atlas all consume it.
|
|
18
|
+
*
|
|
19
|
+
* PURE + TRANSPORT-AGNOSTIC (same discipline as `catalog.ts`): this is the
|
|
20
|
+
* ONLY module `synap-cli`, `browser`, `synap-control-plane-api`, and
|
|
21
|
+
* `synap-backend` can all resolve. No React, no dagre/d3, no fetch, no
|
|
22
|
+
* Node-only APIs. Pixel layout lives elsewhere (browser-only) — this module
|
|
23
|
+
* only computes topology (layers + edges + memoized nodes), never coordinates.
|
|
24
|
+
*
|
|
25
|
+
* CONSUMES drift, never recomputes it: `installed` / `updates` are read
|
|
26
|
+
* straight off `catalog.ts`'s `InstalledTemplate`-shaped rows and
|
|
27
|
+
* `computeTemplateUpdates()` output — this module does no semver comparison
|
|
28
|
+
* of its own.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { TemplateDependency } from "./types.js";
|
|
32
|
+
import { getWorkspaceTemplate } from "./templates.js";
|
|
33
|
+
import type { TemplateUpdateCheck } from "./catalog.js";
|
|
34
|
+
|
|
35
|
+
// ============================================================================
|
|
36
|
+
// Inputs
|
|
37
|
+
// ============================================================================
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An installed template row, keyed by slug — the shape this engine needs to
|
|
41
|
+
* mark a node `installed` and populate `workspaceIds`.
|
|
42
|
+
*
|
|
43
|
+
* NOTE ON NAMING: `catalog.ts`'s `InstalledTemplate` is `{ slug, version }`
|
|
44
|
+
* only — it was built for `computeTemplateUpdates`'s narrower need (a version
|
|
45
|
+
* to diff) and carries no `workspaceId`. `CompositionNode.workspaceIds` is
|
|
46
|
+
* part of the ratified API and cannot be populated from that shape, so this
|
|
47
|
+
* is a REAL conflict, not a preference: this module declares its own,
|
|
48
|
+
* slightly richer row instead of importing `catalog.ts`'s `InstalledTemplate`
|
|
49
|
+
* under that name. Every field of `catalog.ts`'s `InstalledTemplate` is a
|
|
50
|
+
* subset of this one, so a caller building `InstalledTemplate[]` for
|
|
51
|
+
* `computeTemplateUpdates` can build ONE array of `CompositionInstalledEntry`
|
|
52
|
+
* and pass it to both doors.
|
|
53
|
+
*/
|
|
54
|
+
export interface CompositionInstalledEntry {
|
|
55
|
+
slug: string;
|
|
56
|
+
workspaceId: string;
|
|
57
|
+
workspaceName?: string;
|
|
58
|
+
/** `settings.packageVersion`, when the pod stamped one. Omit when unknown. */
|
|
59
|
+
version?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One compose-relation dependency, attached to the BASE node it overlays onto. */
|
|
63
|
+
export interface CompositionOverlay {
|
|
64
|
+
slug: string;
|
|
65
|
+
name: string;
|
|
66
|
+
installed: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One edge in the raw dependency graph, exactly as declared (kept even when broken by a cycle). */
|
|
70
|
+
export interface CompositionEdge {
|
|
71
|
+
from: string;
|
|
72
|
+
to: string;
|
|
73
|
+
relation: "compose" | "require";
|
|
74
|
+
depKind?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CompositionNode {
|
|
78
|
+
slug: string;
|
|
79
|
+
name: string;
|
|
80
|
+
/** Longest-path depth: `1 + max(layer of every node that depends on this one)`. Roots = 0, bedrock = max. */
|
|
81
|
+
layer: number;
|
|
82
|
+
/** TRUE fan-in — every slug (node or compose-only overlay source) that declares a dependency on this one. */
|
|
83
|
+
dependents: string[];
|
|
84
|
+
/** Which of the input `roots` transitively reach this node (self-inclusive for a root). */
|
|
85
|
+
roots: string[];
|
|
86
|
+
installed: boolean;
|
|
87
|
+
workspaceIds: string[];
|
|
88
|
+
installedVersion?: string;
|
|
89
|
+
latestVersion?: string | null;
|
|
90
|
+
updateAvailable: boolean;
|
|
91
|
+
noVersionInfo: boolean;
|
|
92
|
+
/** `compose`-relation deps that overlay onto THIS node (see rule 2 — the overlay source itself is never a layer node). */
|
|
93
|
+
overlays: CompositionOverlay[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface CompositionGraph {
|
|
97
|
+
nodes: Map<string, CompositionNode>;
|
|
98
|
+
edges: CompositionEdge[];
|
|
99
|
+
/** `layers[0]` = roots … `layers[n]` = bedrock. Compose-overlay SOURCES never appear here (rule 2). */
|
|
100
|
+
layers: CompositionNode[][];
|
|
101
|
+
/** Broken back-edges `[from, to]` — surfaced, never silently swallowed (rule 4). */
|
|
102
|
+
cycles: string[][];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface LayerTemplateGraphInput {
|
|
106
|
+
/** Top-level slugs to walk from. Always land at `layer: 0`. */
|
|
107
|
+
roots: string[];
|
|
108
|
+
/** INJECTED edge source (rule 5) — never hardcoded to the bundled map. See `bundledEdgesOf`. */
|
|
109
|
+
edgesOf: (slug: string) => readonly TemplateDependency[];
|
|
110
|
+
nameOf?: (slug: string) => string | undefined;
|
|
111
|
+
installed?: CompositionInstalledEntry[];
|
|
112
|
+
updates?: TemplateUpdateCheck[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Default `edgesOf` — reads `dependencies[]` off the BUNDLED template map
|
|
117
|
+
* only. This is what the current CLI walker is hardcoded to, which is
|
|
118
|
+
* precisely the live bug rule 5 fixes: a remote/private CP-only package's
|
|
119
|
+
* `definition.dependencies` are invisible to it, so its subtree silently
|
|
120
|
+
* renders empty. Callers with a remote catalog entry available should pass
|
|
121
|
+
* their OWN `edgesOf` that falls back to `entry.remote.definition.dependencies`
|
|
122
|
+
* (or similar) for slugs this default can't see.
|
|
123
|
+
*/
|
|
124
|
+
export function bundledEdgesOf(slug: string): readonly TemplateDependency[] {
|
|
125
|
+
return getWorkspaceTemplate(slug)?.dependencies ?? [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// The engine
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
export function layerTemplateGraph(input: LayerTemplateGraphInput): CompositionGraph {
|
|
133
|
+
const { roots, edgesOf } = input;
|
|
134
|
+
const nameOf = input.nameOf ?? (() => undefined);
|
|
135
|
+
const rootSet = new Set(roots);
|
|
136
|
+
|
|
137
|
+
const installedBySlug = new Map<string, CompositionInstalledEntry[]>();
|
|
138
|
+
for (const entry of input.installed ?? []) {
|
|
139
|
+
const list = installedBySlug.get(entry.slug);
|
|
140
|
+
if (list) list.push(entry);
|
|
141
|
+
else installedBySlug.set(entry.slug, [entry]);
|
|
142
|
+
}
|
|
143
|
+
const updatesBySlug = new Map((input.updates ?? []).map((u) => [u.slug, u]));
|
|
144
|
+
|
|
145
|
+
// ── Phase 1: discovery — walk every root, global memo (rule 1) ───────────
|
|
146
|
+
const edges: CompositionEdge[] = [];
|
|
147
|
+
const cycles: string[][] = [];
|
|
148
|
+
/** Every edge target/source touched, whether or not it ends up a layer node. */
|
|
149
|
+
const touched = new Set<string>(roots);
|
|
150
|
+
/** dependent -> Set(dependency) for EVERY edge (both relations) — reachability adjacency. */
|
|
151
|
+
const adjacency = new Map<string, Set<string>>();
|
|
152
|
+
/** dependency -> Set(dependent) for every NON-broken edge (both relations) — true fan-in + layering DP. */
|
|
153
|
+
const dependentsMap = new Map<string, Set<string>>();
|
|
154
|
+
/** Compose-relation edges, kept separately to build overlays + the node-exclusion set. */
|
|
155
|
+
const composeEdges: Array<{ from: string; to: string; depKind?: string }> = [];
|
|
156
|
+
/** A slug's `edgesOf` has already been walked — prevents re-expanding a shared node's children (rule 1). */
|
|
157
|
+
const discovered = new Set<string>();
|
|
158
|
+
|
|
159
|
+
function addAdjacency(from: string, to: string): void {
|
|
160
|
+
const set = adjacency.get(from);
|
|
161
|
+
if (set) set.add(to);
|
|
162
|
+
else adjacency.set(from, new Set([to]));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function addDependent(dependency: string, dependent: string): void {
|
|
166
|
+
const set = dependentsMap.get(dependency);
|
|
167
|
+
if (set) set.add(dependent);
|
|
168
|
+
else dependentsMap.set(dependency, new Set([dependent]));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function walk(slug: string, ancestors: Set<string>): void {
|
|
172
|
+
const deps = edgesOf(slug) ?? [];
|
|
173
|
+
for (const dep of deps) {
|
|
174
|
+
const relation: "compose" | "require" = dep.relation ?? "require";
|
|
175
|
+
touched.add(dep.slug);
|
|
176
|
+
|
|
177
|
+
if (ancestors.has(dep.slug)) {
|
|
178
|
+
// Broken back-edge — surfaced, not swallowed (rule 4). Still recorded
|
|
179
|
+
// in `edges` (the honest picture of what was declared), but excluded
|
|
180
|
+
// from `dependentsMap`/`adjacency` so it can't loop the DP/BFS below.
|
|
181
|
+
cycles.push([slug, dep.slug]);
|
|
182
|
+
edges.push({ from: slug, to: dep.slug, relation, depKind: dep.kind });
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
edges.push({ from: slug, to: dep.slug, relation, depKind: dep.kind });
|
|
187
|
+
addAdjacency(slug, dep.slug);
|
|
188
|
+
addDependent(dep.slug, slug);
|
|
189
|
+
if (relation === "compose") {
|
|
190
|
+
composeEdges.push({ from: slug, to: dep.slug, depKind: dep.kind });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!discovered.has(dep.slug)) {
|
|
194
|
+
discovered.add(dep.slug);
|
|
195
|
+
const nextAncestors = new Set(ancestors);
|
|
196
|
+
nextAncestors.add(slug);
|
|
197
|
+
walk(dep.slug, nextAncestors);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const root of roots) {
|
|
203
|
+
if (discovered.has(root)) continue;
|
|
204
|
+
discovered.add(root);
|
|
205
|
+
walk(root, new Set([root]));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Phase 2: compose-relation sources are never layer nodes (rule 2) ─────
|
|
209
|
+
const composeSources = new Set(composeEdges.map((e) => e.from));
|
|
210
|
+
const overlaysByBase = new Map<string, CompositionOverlay[]>();
|
|
211
|
+
for (const e of composeEdges) {
|
|
212
|
+
const isInstalled = installedBySlug.has(e.from);
|
|
213
|
+
const overlay: CompositionOverlay = { slug: e.from, name: nameOf(e.from) ?? e.from, installed: isInstalled };
|
|
214
|
+
const list = overlaysByBase.get(e.to);
|
|
215
|
+
if (list) list.push(overlay);
|
|
216
|
+
else overlaysByBase.set(e.to, [overlay]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Phase 3: which touched slugs are real layer nodes ────────────────────
|
|
220
|
+
const nodeSlugs = [...touched].filter((s) => !composeSources.has(s)).sort();
|
|
221
|
+
|
|
222
|
+
// ── Phase 4: reachability — which input roots reach each slug ────────────
|
|
223
|
+
const rootsReaching = new Map<string, Set<string>>();
|
|
224
|
+
for (const root of roots) {
|
|
225
|
+
const visited = new Set<string>([root]);
|
|
226
|
+
const queue: string[] = [root];
|
|
227
|
+
const addReach = (s: string) => {
|
|
228
|
+
const set = rootsReaching.get(s);
|
|
229
|
+
if (set) set.add(root);
|
|
230
|
+
else rootsReaching.set(s, new Set([root]));
|
|
231
|
+
};
|
|
232
|
+
addReach(root);
|
|
233
|
+
while (queue.length) {
|
|
234
|
+
const cur = queue.shift()!;
|
|
235
|
+
for (const next of adjacency.get(cur) ?? []) {
|
|
236
|
+
if (visited.has(next)) continue;
|
|
237
|
+
visited.add(next);
|
|
238
|
+
addReach(next);
|
|
239
|
+
queue.push(next);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Phase 5: longest-path layering (rule 1) ───────────────────────────────
|
|
245
|
+
// layer(n) = 1 + max(layer of every node that depends on n); roots = 0.
|
|
246
|
+
// Works for ANY slug (node or compose-only overlay source) so a compose
|
|
247
|
+
// source's OWN layer still feeds the DP for the base it composes onto —
|
|
248
|
+
// e.g. `grants` (excluded from `nodes`) still positions `operations` one
|
|
249
|
+
// layer below wherever `grants` itself sits.
|
|
250
|
+
const layerMemo = new Map<string, number>();
|
|
251
|
+
function layerOf(slug: string, stack: Set<string>): number {
|
|
252
|
+
if (rootSet.has(slug)) return 0;
|
|
253
|
+
const memoized = layerMemo.get(slug);
|
|
254
|
+
if (memoized !== undefined) return memoized;
|
|
255
|
+
if (stack.has(slug)) return 0; // defensive — cycle edges are already excluded from dependentsMap
|
|
256
|
+
stack.add(slug);
|
|
257
|
+
const dependents = dependentsMap.get(slug);
|
|
258
|
+
const layer = !dependents || dependents.size === 0
|
|
259
|
+
? 0
|
|
260
|
+
: 1 + Math.max(...[...dependents].map((d) => layerOf(d, stack)));
|
|
261
|
+
stack.delete(slug);
|
|
262
|
+
layerMemo.set(slug, layer);
|
|
263
|
+
return layer;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Phase 6: build nodes ──────────────────────────────────────────────────
|
|
267
|
+
const nodes = new Map<string, CompositionNode>();
|
|
268
|
+
for (const slug of nodeSlugs) {
|
|
269
|
+
const installedEntries = installedBySlug.get(slug) ?? [];
|
|
270
|
+
const workspaceIds = installedEntries.map((e) => e.workspaceId).filter(Boolean);
|
|
271
|
+
const installed = workspaceIds.length > 0;
|
|
272
|
+
const update = updatesBySlug.get(slug);
|
|
273
|
+
const installedVersion = update?.installedVersion || installedEntries[0]?.version;
|
|
274
|
+
const noVersionInfo = installed && (update === undefined || update.latestVersion === undefined);
|
|
275
|
+
|
|
276
|
+
nodes.set(slug, {
|
|
277
|
+
slug,
|
|
278
|
+
name: nameOf(slug) ?? slug,
|
|
279
|
+
layer: layerOf(slug, new Set()),
|
|
280
|
+
dependents: [...(dependentsMap.get(slug) ?? [])].sort(),
|
|
281
|
+
roots: [...(rootsReaching.get(slug) ?? [])].sort(),
|
|
282
|
+
installed,
|
|
283
|
+
workspaceIds,
|
|
284
|
+
installedVersion,
|
|
285
|
+
latestVersion: update?.latestVersion ?? null,
|
|
286
|
+
updateAvailable: update?.updateAvailable ?? false,
|
|
287
|
+
noVersionInfo,
|
|
288
|
+
overlays: overlaysByBase.get(slug) ?? [],
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const maxLayer = Math.max(0, ...[...nodes.values()].map((n) => n.layer));
|
|
293
|
+
const layers: CompositionNode[][] = Array.from({ length: maxLayer + 1 }, () => []);
|
|
294
|
+
for (const node of nodes.values()) layers[node.layer].push(node);
|
|
295
|
+
for (const bucket of layers) bucket.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
296
|
+
|
|
297
|
+
return { nodes, edges, layers, cycles };
|
|
298
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concept-guard tests.
|
|
3
|
+
*
|
|
4
|
+
* The first test is the gate: the SHIPPED corpus must be free of undeclared
|
|
5
|
+
* vocabulary forks. The rest prove the guard actually fires — a guard nobody
|
|
6
|
+
* has watched fail is decoration, so the failure cases are pinned here rather
|
|
7
|
+
* than demonstrated once by hand and forgotten.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "vitest";
|
|
11
|
+
import {
|
|
12
|
+
DECLARED_DIVERGENCES,
|
|
13
|
+
conceptTerm,
|
|
14
|
+
findConceptViolations,
|
|
15
|
+
formatConceptViolations,
|
|
16
|
+
} from "./concept-guard.js";
|
|
17
|
+
import { WORKSPACE_TEMPLATES } from "./templates.js";
|
|
18
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** Minimal fixture corpus — only the fields the guard reads. */
|
|
21
|
+
function corpus(
|
|
22
|
+
templates: Record<string, Array<Record<string, unknown>>>
|
|
23
|
+
): Record<string, WorkspaceYaml> {
|
|
24
|
+
return Object.fromEntries(
|
|
25
|
+
Object.entries(templates).map(([slug, profiles]) => [slug, { profiles }])
|
|
26
|
+
) as unknown as Record<string, WorkspaceYaml>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const role = (slug: string, extra: Record<string, unknown> = {}) => ({
|
|
30
|
+
slug,
|
|
31
|
+
displayName: slug,
|
|
32
|
+
profileKind: "role",
|
|
33
|
+
properties: [],
|
|
34
|
+
...extra,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("concept guard — shipped corpus", () => {
|
|
38
|
+
it("has no undeclared cross-template concept forks", () => {
|
|
39
|
+
const violations = findConceptViolations();
|
|
40
|
+
expect(
|
|
41
|
+
violations,
|
|
42
|
+
violations.length ? `\n${formatConceptViolations(violations)}\n` : ""
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("still sees the two-tier CRM divergence — declared, not absent", () => {
|
|
47
|
+
// If business-developer's crm-lead/crm-client ever stopped colliding with
|
|
48
|
+
// foundation's lead/client, the allowlist would be stale and rule 3 would
|
|
49
|
+
// fire. This asserts the allowlist is load-bearing, not inert.
|
|
50
|
+
const withoutAllowlist = Object.keys(DECLARED_DIVERGENCES);
|
|
51
|
+
expect(withoutAllowlist).toEqual(["crm-lead|lead", "client|crm-client"]);
|
|
52
|
+
expect(WORKSPACE_TEMPLATES["business-developer"]?.profiles.find((p) => p.slug === "crm-lead")
|
|
53
|
+
?.semanticSlug).toBe("crm.lead");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("conceptTerm", () => {
|
|
58
|
+
it("reduces a qualified concept identifier to its bare noun", () => {
|
|
59
|
+
expect(conceptTerm("lead")).toBe("lead");
|
|
60
|
+
expect(conceptTerm("crm.lead")).toBe("lead");
|
|
61
|
+
expect(conceptTerm("crm-lead")).toBe("lead");
|
|
62
|
+
expect(conceptTerm("marketing.contact")).toBe("contact");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("concept guard — fires on a real fork", () => {
|
|
67
|
+
it("fails when a second template renames an existing role concept", () => {
|
|
68
|
+
const violations = findConceptViolations(
|
|
69
|
+
corpus({
|
|
70
|
+
foundation: [role("partner", { scope: "shared", applicableKinds: ["company"] })],
|
|
71
|
+
"some-addon": [role("channel-partner", { scope: "WORKSPACE" })],
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
// Fixture corpora never match the real allowlist, so the stale-allowlist
|
|
75
|
+
// findings are expected noise here — the collision is what is under test.
|
|
76
|
+
const forks = violations.filter((v) => v.rule !== "stale-allowlist");
|
|
77
|
+
expect(forks).toHaveLength(1);
|
|
78
|
+
expect(forks[0]!.rule).toBe("concept-collision");
|
|
79
|
+
expect(forks[0]!.concept).toBe("partner");
|
|
80
|
+
expect(forks[0]!.message).toContain("channel-partner");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("suppresses exactly the pair an author declared — and nothing else", () => {
|
|
84
|
+
// The SAME shape as the test above, but with the slugs the allowlist names.
|
|
85
|
+
// This is the whole safety property of the escape hatch: it is per-pair, not
|
|
86
|
+
// per-concept, so a THIRD `lead` spelling would still fail.
|
|
87
|
+
const declared = findConceptViolations(
|
|
88
|
+
corpus({
|
|
89
|
+
foundation: [role("lead", { scope: "shared" })],
|
|
90
|
+
"business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
expect(declared.filter((v) => v.rule === "concept-collision")).toEqual([]);
|
|
94
|
+
|
|
95
|
+
const third = findConceptViolations(
|
|
96
|
+
corpus({
|
|
97
|
+
foundation: [role("lead", { scope: "shared" })],
|
|
98
|
+
"business-developer": [role("crm-lead", { semanticSlug: "crm.lead", scope: "WORKSPACE" })],
|
|
99
|
+
"another-addon": [role("sales-lead", { scope: "WORKSPACE" })],
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
expect(third.filter((v) => v.rule === "concept-collision")).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("fires even when the fork hides behind a namespaced semanticSlug", () => {
|
|
106
|
+
const violations = findConceptViolations(
|
|
107
|
+
corpus({
|
|
108
|
+
foundation: [role("lead")],
|
|
109
|
+
other: [role("pipeline-lead", { semanticSlug: "sales.lead" })],
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
expect(violations.filter((v) => v.rule !== "stale-allowlist").map((v) => v.rule)).toEqual([
|
|
113
|
+
"concept-collision",
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("fails when two aliased profiles disagree on shape", () => {
|
|
118
|
+
const violations = findConceptViolations(
|
|
119
|
+
corpus({
|
|
120
|
+
a: [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
|
|
121
|
+
b: [role("promo", { semanticSlug: "marketing.campaign", scope: "WORKSPACE" })],
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
expect(violations.map((v) => v.rule)).toContain("alias-shape-divergence");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("fails when an allowlist entry stops matching anything", () => {
|
|
128
|
+
// DECLARED_DIVERGENCES is module-level, so an empty corpus makes every
|
|
129
|
+
// entry stale — exactly the state a resolved divergence would leave behind.
|
|
130
|
+
const violations = findConceptViolations(corpus({}));
|
|
131
|
+
expect(violations.every((v) => v.rule === "stale-allowlist")).toBe(true);
|
|
132
|
+
expect(violations).toHaveLength(Object.keys(DECLARED_DIVERGENCES).length);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("concept guard — stays green on legitimate patterns", () => {
|
|
137
|
+
it("allows the same slug re-declared across templates (overlay mechanism)", () => {
|
|
138
|
+
const violations = findConceptViolations(
|
|
139
|
+
corpus({
|
|
140
|
+
foundation: [role("lead", { scope: "shared", applicableKinds: ["person", "company"] })],
|
|
141
|
+
// A consumer legitimately OMITS row-level fields it does not own.
|
|
142
|
+
marketing: [role("lead", { scope: "shared" })],
|
|
143
|
+
})
|
|
144
|
+
);
|
|
145
|
+
expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("allows sibling roles authored in ONE template to share a noun", () => {
|
|
149
|
+
const violations = findConceptViolations(
|
|
150
|
+
corpus({
|
|
151
|
+
"blockchain-ecosystem": [role("marketing-agency"), role("paid-ads-agency")],
|
|
152
|
+
})
|
|
153
|
+
);
|
|
154
|
+
expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("allows an identical concept reused under one semanticSlug", () => {
|
|
158
|
+
const violations = findConceptViolations(
|
|
159
|
+
corpus({
|
|
160
|
+
"content-os": [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
|
|
161
|
+
marketing: [role("campaign", { semanticSlug: "marketing.campaign", scope: "shared" })],
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("does not police non-role profiles", () => {
|
|
168
|
+
const violations = findConceptViolations(
|
|
169
|
+
corpus({
|
|
170
|
+
a: [{ slug: "note", displayName: "Note", properties: [] }],
|
|
171
|
+
b: [{ slug: "meeting-note", displayName: "Meeting Note", properties: [] }],
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
expect(violations.filter((v) => v.rule !== "stale-allowlist")).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
});
|