@arcote.tech/arc-map 0.7.27
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/package.json +38 -0
- package/src/app/arc.tsx +92 -0
- package/src/app/contexts.tsx +72 -0
- package/src/app/detail-panel.tsx +164 -0
- package/src/app/index.tsx +107 -0
- package/src/app/layout.ts +350 -0
- package/src/app/map-view.tsx +225 -0
- package/src/app/mdx-view.tsx +74 -0
- package/src/app/nodes.tsx +478 -0
- package/src/app/ref-context.tsx +49 -0
- package/src/app/structure.tsx +95 -0
- package/src/app/subgraph.ts +127 -0
- package/src/app/theme.ts +67 -0
- package/src/app/usecase-layout.ts +249 -0
- package/src/app/usecase-model.ts +346 -0
- package/src/app/usecase-tree.tsx +104 -0
- package/src/app/usecase-view.tsx +173 -0
- package/src/index.ts +18 -0
- package/src/mapper.ts +392 -0
- package/src/module-index.ts +70 -0
- package/src/refs.ts +121 -0
- package/src/scan.ts +393 -0
- package/src/server.ts +262 -0
- package/src/types.ts +154 -0
- package/src/use-cases/discover.ts +109 -0
- package/tests/discover.test.ts +42 -0
- package/tests/fixtures/pkg/detail-page.tsx +11 -0
- package/tests/fixtures/pkg/direct.tsx +7 -0
- package/tests/fixtures/pkg/list-page.tsx +11 -0
- package/tests/fixtures/pkg/routes.ts +6 -0
- package/tests/fixtures/pkg/scope.ts +4 -0
- package/tests/fixtures/uc/pkgA/use-cases/bar.mdx +3 -0
- package/tests/fixtures/uc/pkgA/use-cases/sub/foo.mdx +9 -0
- package/tests/mapper.test.ts +115 -0
- package/tests/refs.test.ts +56 -0
- package/tests/scan.test.ts +71 -0
- package/tests/subgraph.test.ts +54 -0
- package/tests/usecase-model.test.ts +197 -0
- package/tsconfig.json +8 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON shape exchanged between the map server (`/api/map`) and the
|
|
3
|
+
* React Flow front-end. Kept dependency-free so both Node/Bun and the browser
|
|
4
|
+
* bundle can import it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Kind of a context element, derived from its runtime metadata. */
|
|
8
|
+
export type ElementKind =
|
|
9
|
+
| "aggregate"
|
|
10
|
+
| "command"
|
|
11
|
+
| "event"
|
|
12
|
+
| "view"
|
|
13
|
+
| "static-view"
|
|
14
|
+
| "listener"
|
|
15
|
+
| "route"
|
|
16
|
+
| "page"
|
|
17
|
+
| "unknown";
|
|
18
|
+
|
|
19
|
+
/** Kind of a domain edge between two context elements. */
|
|
20
|
+
export type EdgeKind = "emits" | "handles" | "listens" | "queries" | "mutates";
|
|
21
|
+
|
|
22
|
+
/** A space (bounded context) — the grouping box on the map. */
|
|
23
|
+
export interface MapSpace {
|
|
24
|
+
/** Stable `__contextId`, or `"ungrouped"` for module-less elements. */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Human label: `__contextName` when set, otherwise the id. */
|
|
27
|
+
name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A callable method exposed by an element (aggregate methods, etc.). */
|
|
31
|
+
export interface MapElementMethod {
|
|
32
|
+
name: string;
|
|
33
|
+
kind: "mutate" | "query" | "cron" | "event";
|
|
34
|
+
description?: string;
|
|
35
|
+
/** Element names this method reads (per-method query dependencies). */
|
|
36
|
+
queryDeps?: string[];
|
|
37
|
+
/** Element names this method writes (per-method mutation dependencies). */
|
|
38
|
+
mutationDeps?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A single context element node. */
|
|
42
|
+
export interface MapElement {
|
|
43
|
+
/** Element name — unique within the runtime context. */
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
kind: ElementKind;
|
|
47
|
+
/** Space (context) this element belongs to. */
|
|
48
|
+
spaceId: string;
|
|
49
|
+
/** Owning `module()` id/name, when the element was attached to a module. */
|
|
50
|
+
moduleId?: string;
|
|
51
|
+
moduleName?: string;
|
|
52
|
+
/** Module scope tag (e.g. `user` / `workspace`), when known. */
|
|
53
|
+
scope?: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
/** Protection token names (from `protectedBy`). */
|
|
56
|
+
protections?: string[];
|
|
57
|
+
/** JSON schema of the element's primary data shape, when derivable. */
|
|
58
|
+
schema?: unknown;
|
|
59
|
+
/** Command/route params schema. */
|
|
60
|
+
params?: unknown;
|
|
61
|
+
/** Command results schema. */
|
|
62
|
+
results?: unknown;
|
|
63
|
+
/** Event payload schema. */
|
|
64
|
+
payload?: unknown;
|
|
65
|
+
/** Callable methods (mutate/query/cron) declared by the element. */
|
|
66
|
+
methods?: MapElementMethod[];
|
|
67
|
+
/** Event names an aggregate declares/emits. */
|
|
68
|
+
events?: string[];
|
|
69
|
+
/** HTTP path for route elements. */
|
|
70
|
+
path?: string;
|
|
71
|
+
/** Element-level query deps (command / listener / route). */
|
|
72
|
+
queryDeps?: string[];
|
|
73
|
+
/** Element-level mutation deps (command / listener / route). */
|
|
74
|
+
mutationDeps?: string[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A domain edge between two elements (event-flow / data dependency). */
|
|
78
|
+
export interface MapEdge {
|
|
79
|
+
id: string;
|
|
80
|
+
/** Source element id. */
|
|
81
|
+
source: string;
|
|
82
|
+
/** Target element id. */
|
|
83
|
+
target: string;
|
|
84
|
+
kind: EdgeKind;
|
|
85
|
+
/** True when source and target live in different spaces. */
|
|
86
|
+
crossContext: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A React component (or hook) that consumes context elements via a scope. */
|
|
90
|
+
export interface MapComponent {
|
|
91
|
+
/** Stable id — workspace-relative file path. */
|
|
92
|
+
id: string;
|
|
93
|
+
/** Workspace-relative source file. */
|
|
94
|
+
file: string;
|
|
95
|
+
/** Component/function name, when resolvable. */
|
|
96
|
+
name?: string;
|
|
97
|
+
/** Owning workspace package name. */
|
|
98
|
+
package?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** An edge from a React component to a context element it uses. */
|
|
102
|
+
export interface MapComponentEdge {
|
|
103
|
+
id: string;
|
|
104
|
+
/** Component id (file). */
|
|
105
|
+
source: string;
|
|
106
|
+
/** Target element id (element name). */
|
|
107
|
+
target: string;
|
|
108
|
+
/** Whether the element is read (`query`) or written (`mutation`). */
|
|
109
|
+
usage: "query" | "mutation";
|
|
110
|
+
/** Scope name the component used (e.g. `user` / `workspace`). */
|
|
111
|
+
scope?: string;
|
|
112
|
+
/** Methods invoked on the element (e.g. `getAll`, `rename`). */
|
|
113
|
+
methods: string[];
|
|
114
|
+
/** 1-based line of the first usage in the source file. */
|
|
115
|
+
line?: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Full payload returned by `GET /api/map`. */
|
|
119
|
+
export interface MapJson {
|
|
120
|
+
spaces: MapSpace[];
|
|
121
|
+
elements: MapElement[];
|
|
122
|
+
edges: MapEdge[];
|
|
123
|
+
components: MapComponent[];
|
|
124
|
+
componentEdges: MapComponentEdge[];
|
|
125
|
+
/** ISO timestamp stamped by the server when the payload was built. */
|
|
126
|
+
generatedAt?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The id used for elements that never belonged to any context/module. */
|
|
130
|
+
export const UNGROUPED_SPACE_ID = "ungrouped";
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Use cases
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** A use-case MDX file discovered under `packages/<pkg>/use-cases/**`. */
|
|
137
|
+
export interface UseCaseEntry {
|
|
138
|
+
/** Stable unique id — `${package}/${subPath}`. */
|
|
139
|
+
id: string;
|
|
140
|
+
/** Owning workspace package name (e.g. `@ndt/auth`). */
|
|
141
|
+
package: string;
|
|
142
|
+
/** Path relative to the package's `use-cases/` dir (e.g. `sub/renew.mdx`). */
|
|
143
|
+
subPath: string;
|
|
144
|
+
/** Title from frontmatter, falling back to the filename. */
|
|
145
|
+
title: string;
|
|
146
|
+
description?: string;
|
|
147
|
+
/** Optional ordering hint from frontmatter. */
|
|
148
|
+
order?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Response of `GET /api/use-cases`. */
|
|
152
|
+
export interface UseCaseListJson {
|
|
153
|
+
useCases: UseCaseEntry[];
|
|
154
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover use-case MDX files under `packages/<pkg>/use-cases/**` and read
|
|
3
|
+
* their frontmatter. Server-side only (Node/Bun fs). The discovered entries
|
|
4
|
+
* carry a package-relative `subPath` so the front can rebuild the folder tree.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { UseCaseEntry } from "../types";
|
|
10
|
+
|
|
11
|
+
export interface UseCasePackage {
|
|
12
|
+
name: string;
|
|
13
|
+
/** Absolute package directory. */
|
|
14
|
+
path: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiscoveredUseCase extends UseCaseEntry {
|
|
18
|
+
/** Absolute file path — server-only, not sent to the client. */
|
|
19
|
+
absPath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minimal frontmatter parse: leading `---\n...\n---` block of `key: value` lines. */
|
|
23
|
+
export function parseFrontmatter(text: string): {
|
|
24
|
+
data: Record<string, string>;
|
|
25
|
+
body: string;
|
|
26
|
+
} {
|
|
27
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text);
|
|
28
|
+
if (!match) return { data: {}, body: text };
|
|
29
|
+
const data: Record<string, string> = {};
|
|
30
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
31
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
32
|
+
if (!m) continue;
|
|
33
|
+
let value = m[2].trim();
|
|
34
|
+
// strip surrounding quotes
|
|
35
|
+
if (
|
|
36
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
37
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
38
|
+
) {
|
|
39
|
+
value = value.slice(1, -1);
|
|
40
|
+
}
|
|
41
|
+
data[m[1]] = value;
|
|
42
|
+
}
|
|
43
|
+
return { data, body: text.slice(match[0].length) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function walkMdx(dir: string, base: string, out: string[]): void {
|
|
47
|
+
let entries: string[];
|
|
48
|
+
try {
|
|
49
|
+
entries = readdirSync(dir) as string[];
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (const name of entries) {
|
|
54
|
+
const full = join(dir, name);
|
|
55
|
+
let s;
|
|
56
|
+
try {
|
|
57
|
+
s = statSync(full);
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (s.isDirectory()) walkMdx(full, base, out);
|
|
62
|
+
else if (name.endsWith(".mdx")) out.push(full);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Discover all use-case MDX files across the given packages. */
|
|
67
|
+
export function discoverUseCases(packages: UseCasePackage[]): DiscoveredUseCase[] {
|
|
68
|
+
const result: DiscoveredUseCase[] = [];
|
|
69
|
+
for (const pkg of packages) {
|
|
70
|
+
const ucDir = join(pkg.path, "use-cases");
|
|
71
|
+
if (!existsSync(ucDir)) continue;
|
|
72
|
+
const files: string[] = [];
|
|
73
|
+
walkMdx(ucDir, ucDir, files);
|
|
74
|
+
for (const abs of files) {
|
|
75
|
+
const subPath = abs.slice(ucDir.length + 1).split("\\").join("/");
|
|
76
|
+
let title = subPath.replace(/\.mdx$/, "");
|
|
77
|
+
let description: string | undefined;
|
|
78
|
+
let order: number | undefined;
|
|
79
|
+
try {
|
|
80
|
+
const { data } = parseFrontmatter(readFileSync(abs, "utf-8"));
|
|
81
|
+
if (data.title) title = data.title;
|
|
82
|
+
if (data.description) description = data.description;
|
|
83
|
+
if (data.order !== undefined && data.order !== "") {
|
|
84
|
+
const n = Number(data.order);
|
|
85
|
+
if (!Number.isNaN(n)) order = n;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// unreadable file — keep filename title
|
|
89
|
+
}
|
|
90
|
+
result.push({
|
|
91
|
+
id: `${pkg.name}/${subPath}`,
|
|
92
|
+
package: pkg.name,
|
|
93
|
+
subPath,
|
|
94
|
+
title,
|
|
95
|
+
description,
|
|
96
|
+
order,
|
|
97
|
+
absPath: abs,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Stable sort: package, then order (if any), then subPath.
|
|
102
|
+
result.sort(
|
|
103
|
+
(a, b) =>
|
|
104
|
+
a.package.localeCompare(b.package) ||
|
|
105
|
+
(a.order ?? 1e9) - (b.order ?? 1e9) ||
|
|
106
|
+
a.subPath.localeCompare(b.subPath),
|
|
107
|
+
);
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { discoverUseCases, parseFrontmatter } from "../src/use-cases/discover";
|
|
4
|
+
|
|
5
|
+
const PKG_A = join(import.meta.dir, "fixtures/uc/pkgA");
|
|
6
|
+
|
|
7
|
+
describe("parseFrontmatter", () => {
|
|
8
|
+
test("extracts key: value block, strips quotes, returns body", () => {
|
|
9
|
+
const { data, body } = parseFrontmatter(`---\ntitle: "Hi"\norder: 3\n---\nbody here`);
|
|
10
|
+
expect(data.title).toBe("Hi");
|
|
11
|
+
expect(data.order).toBe("3");
|
|
12
|
+
expect(body.trim()).toBe("body here");
|
|
13
|
+
});
|
|
14
|
+
test("no frontmatter → empty data, full body", () => {
|
|
15
|
+
const { data, body } = parseFrontmatter("# just markdown");
|
|
16
|
+
expect(data).toEqual({});
|
|
17
|
+
expect(body).toBe("# just markdown");
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("discoverUseCases", () => {
|
|
22
|
+
test("walks use-cases/**, reads frontmatter, builds ids + subPaths", () => {
|
|
23
|
+
const found = discoverUseCases([{ name: "@x/pkgA", path: PKG_A }]);
|
|
24
|
+
const byId = new Map(found.map((u) => [u.id, u]));
|
|
25
|
+
|
|
26
|
+
const foo = byId.get("@x/pkgA/sub/foo.mdx");
|
|
27
|
+
expect(foo).toBeDefined();
|
|
28
|
+
expect(foo!.title).toBe("Foo use case");
|
|
29
|
+
expect(foo!.order).toBe(2);
|
|
30
|
+
expect(foo!.package).toBe("@x/pkgA");
|
|
31
|
+
expect(foo!.subPath).toBe("sub/foo.mdx");
|
|
32
|
+
|
|
33
|
+
const bar = byId.get("@x/pkgA/bar.mdx");
|
|
34
|
+
expect(bar).toBeDefined();
|
|
35
|
+
// no frontmatter → title falls back to filename-without-ext
|
|
36
|
+
expect(bar!.title).toBe("bar");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("package without use-cases/ dir yields nothing", () => {
|
|
40
|
+
expect(discoverUseCases([{ name: "empty", path: join(import.meta.dir, "fixtures") }])).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { usePlatformScope } from "@arcote.tech/platform";
|
|
2
|
+
import { context } from "./ctx";
|
|
3
|
+
export default function DetailPage() {
|
|
4
|
+
const s = usePlatformScope(context, "user");
|
|
5
|
+
const [me] = s.useQuery().userAccounts.getMe();
|
|
6
|
+
const commands = s.useMutation();
|
|
7
|
+
async function onSave() {
|
|
8
|
+
await commands.userAccounts.update({ id: me?.id });
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useContentScope } from "./scope";
|
|
2
|
+
export function ListPage() {
|
|
3
|
+
const scope = useContentScope();
|
|
4
|
+
const commands = scope.useMutation();
|
|
5
|
+
const [topics] = scope.useQuery().contentTopic.getAll();
|
|
6
|
+
const [contents] = scope.useQuery().content.getAll();
|
|
7
|
+
async function onRename(id: string) {
|
|
8
|
+
await commands.contentTopic.rename({ id });
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { usePlatformScope } from "@arcote.tech/platform";
|
|
2
|
+
import { contentContext } from "./context";
|
|
3
|
+
export const useContentScope = () => usePlatformScope(contentContext, "workspace");
|
|
4
|
+
export const useUserContentScope = () => usePlatformScope(contentContext, "user");
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
aggregate,
|
|
4
|
+
command,
|
|
5
|
+
context,
|
|
6
|
+
event,
|
|
7
|
+
id,
|
|
8
|
+
listener,
|
|
9
|
+
string,
|
|
10
|
+
} from "@arcote.tech/arc";
|
|
11
|
+
import { buildMapJson, type ModuleInfo } from "../src/mapper";
|
|
12
|
+
import type { MapComponentEdge } from "../src/types";
|
|
13
|
+
|
|
14
|
+
function fixture() {
|
|
15
|
+
const taskId = id("task");
|
|
16
|
+
const Notify = event("notifyCreated", { taskId: string() });
|
|
17
|
+
const Task = aggregate("task", taskId, { title: string() })
|
|
18
|
+
.publicEvent("taskCreated", { taskId, title: string() }, async () => {})
|
|
19
|
+
.mutateMethod("create", (fn: any) => fn.withParams({ title: string() }).handle(async () => {}));
|
|
20
|
+
const createTask = command("createTask")
|
|
21
|
+
.mutate([Notify])
|
|
22
|
+
.withParams({ title: string() })
|
|
23
|
+
.handle(async () => ({}));
|
|
24
|
+
const onCreated = listener("onCreated").listenTo([Notify]).handle(async () => {});
|
|
25
|
+
|
|
26
|
+
const tasks = context([Task, createTask], { name: "tasks" });
|
|
27
|
+
const notifications = context([Notify, onCreated], { name: "notifications" });
|
|
28
|
+
// Runtime view is a single flat merge of both spaces.
|
|
29
|
+
return context([...tasks.elements, ...notifications.elements]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("buildMapJson", () => {
|
|
33
|
+
test("groups elements into named spaces from __contextId", () => {
|
|
34
|
+
const map = buildMapJson({ context: fixture() });
|
|
35
|
+
const spaceNames = map.spaces.map((s) => s.name).sort();
|
|
36
|
+
expect(spaceNames).toEqual(["notifications", "tasks"]);
|
|
37
|
+
|
|
38
|
+
const byId = new Map(map.elements.map((e) => [e.id, e]));
|
|
39
|
+
expect(byId.get("task")!.spaceId).toBe(byId.get("createTask")!.spaceId);
|
|
40
|
+
expect(byId.get("notifyCreated")!.spaceId).not.toBe(byId.get("task")!.spaceId);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("detects element kinds and aggregate metadata", () => {
|
|
44
|
+
const map = buildMapJson({ context: fixture() });
|
|
45
|
+
const byId = new Map(map.elements.map((e) => [e.id, e]));
|
|
46
|
+
expect(byId.get("task")!.kind).toBe("aggregate");
|
|
47
|
+
expect(byId.get("createTask")!.kind).toBe("command");
|
|
48
|
+
expect(byId.get("notifyCreated")!.kind).toBe("event");
|
|
49
|
+
expect(byId.get("onCreated")!.kind).toBe("listener");
|
|
50
|
+
expect(byId.get("task")!.events).toContain("taskCreated");
|
|
51
|
+
expect(byId.get("task")!.methods?.map((m) => m.name)).toContain("create");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("synthesizes a node for inline aggregate events", () => {
|
|
55
|
+
const map = buildMapJson({ context: fixture() });
|
|
56
|
+
const synth = map.elements.find((e) => e.id === "taskCreated");
|
|
57
|
+
expect(synth).toBeDefined();
|
|
58
|
+
expect(synth!.kind).toBe("event");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("builds typed edges and flags cross-context", () => {
|
|
62
|
+
const map = buildMapJson({ context: fixture() });
|
|
63
|
+
const e = (s: string, t: string, k: string) =>
|
|
64
|
+
map.edges.find((x) => x.source === s && x.target === t && x.kind === k);
|
|
65
|
+
|
|
66
|
+
expect(e("task", "taskCreated", "emits")).toBeDefined();
|
|
67
|
+
expect(e("onCreated", "notifyCreated", "listens")).toBeDefined();
|
|
68
|
+
|
|
69
|
+
// createTask (tasks) -mutates-> notifyCreated (notifications) => cross-context.
|
|
70
|
+
const cross = e("createTask", "notifyCreated", "mutates");
|
|
71
|
+
expect(cross).toBeDefined();
|
|
72
|
+
expect(cross!.crossContext).toBe(true);
|
|
73
|
+
|
|
74
|
+
// emits within the same space is not cross-context.
|
|
75
|
+
expect(e("task", "taskCreated", "emits")!.crossContext).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("tags elements with module info", () => {
|
|
79
|
+
const moduleByElement = new Map<string, ModuleInfo>([
|
|
80
|
+
["task", { moduleId: "m1", moduleName: "TasksModule", scope: "workspace" }],
|
|
81
|
+
]);
|
|
82
|
+
const map = buildMapJson({ context: fixture(), moduleByElement });
|
|
83
|
+
const task = map.elements.find((e) => e.id === "task")!;
|
|
84
|
+
expect(task.moduleName).toBe("TasksModule");
|
|
85
|
+
expect(task.scope).toBe("workspace");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resolves namespaced aggregate event in listener listens edge", () => {
|
|
89
|
+
const oid = id("o");
|
|
90
|
+
const Order = aggregate("order", oid, { total: string() })
|
|
91
|
+
.publicEvent("orderPaid", { orderId: oid }, async () => {});
|
|
92
|
+
// Listener listens via getEvent → name may be namespaced (order.orderPaid),
|
|
93
|
+
// but the event node is registered under the short name "orderPaid".
|
|
94
|
+
const onPaid = listener("onPaid")
|
|
95
|
+
.listenTo([(Order as any).getEvent("orderPaid")])
|
|
96
|
+
.handle(async () => {});
|
|
97
|
+
const ctx = context([Order, onPaid]);
|
|
98
|
+
const map = buildMapJson({ context: ctx });
|
|
99
|
+
const listens = map.edges.find((e) => e.kind === "listens" && e.source === "onPaid");
|
|
100
|
+
expect(listens).toBeDefined();
|
|
101
|
+
expect(listens!.target).toBe("orderPaid"); // resolved to the short node id
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("keeps only component edges resolving to a known element", () => {
|
|
105
|
+
const componentEdges: MapComponentEdge[] = [
|
|
106
|
+
{ id: "a", source: "pkg/x.tsx", target: "task", usage: "query", methods: ["getAll"], scope: "workspace" },
|
|
107
|
+
{ id: "b", source: "pkg/x.tsx", target: "doesNotExist", usage: "mutation", methods: [] },
|
|
108
|
+
];
|
|
109
|
+
const components = [{ id: "pkg/x.tsx", file: "pkg/x.tsx" }];
|
|
110
|
+
const map = buildMapJson({ context: fixture(), componentEdges, components });
|
|
111
|
+
expect(map.componentEdges).toHaveLength(1);
|
|
112
|
+
expect(map.componentEdges[0]!.target).toBe("task");
|
|
113
|
+
expect(map.components).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveRef, normalizePagePath } from "../src/refs";
|
|
3
|
+
|
|
4
|
+
describe("normalizePagePath", () => {
|
|
5
|
+
test("strips query, hash and trailing slash; keeps root", () => {
|
|
6
|
+
expect(normalizePagePath("/checkout/success?orderId=1")).toBe("/checkout/success");
|
|
7
|
+
expect(normalizePagePath("/credits#top")).toBe("/credits");
|
|
8
|
+
expect(normalizePagePath("/checkout/")).toBe("/checkout");
|
|
9
|
+
expect(normalizePagePath("/")).toBe("/");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("resolveRef", () => {
|
|
14
|
+
test("page → identified by normalized path (not module key)", () => {
|
|
15
|
+
expect(resolveRef({ page: "/checkout/success?orderId=1" })).toMatchObject({
|
|
16
|
+
nodeId: "/checkout/success",
|
|
17
|
+
kind: "page",
|
|
18
|
+
key: "page:/checkout/success",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
test("command / view / listener / route / event", () => {
|
|
24
|
+
expect(resolveRef({ command: "signIn" })).toMatchObject({ nodeId: "signIn", kind: "command", key: "command:signIn" });
|
|
25
|
+
expect(resolveRef({ view: "orders" })).toMatchObject({ nodeId: "orders", kind: "view" });
|
|
26
|
+
expect(resolveRef({ listener: "onX" })).toMatchObject({ nodeId: "onX", kind: "listener" });
|
|
27
|
+
expect(resolveRef({ route: "webhook" })).toMatchObject({ nodeId: "webhook", kind: "route" });
|
|
28
|
+
expect(resolveRef({ event: "taskCreated" })).toMatchObject({ nodeId: "taskCreated", kind: "event" });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("aggregate alone → aggregate node", () => {
|
|
32
|
+
expect(resolveRef({ aggregate: "tasks" })).toMatchObject({ nodeId: "tasks", kind: "aggregate", key: "aggregate:tasks" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("aggregate + mutationMethod → aggregate node + method", () => {
|
|
36
|
+
const r = resolveRef({ aggregate: "userAccounts", mutationMethod: "checkEmail" });
|
|
37
|
+
expect(r).toMatchObject({
|
|
38
|
+
nodeId: "userAccounts",
|
|
39
|
+
kind: "aggregate",
|
|
40
|
+
method: { name: "checkEmail", kind: "mutation" },
|
|
41
|
+
key: "aggregate:userAccounts#mutate:checkEmail",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("aggregate + queryMethod → query method", () => {
|
|
46
|
+
expect(resolveRef({ aggregate: "tasks", queryMethod: "getAll" })?.method).toEqual({ name: "getAll", kind: "query" });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("aggregate + event → the event node", () => {
|
|
50
|
+
expect(resolveRef({ aggregate: "tasks", event: "taskCreated" })).toMatchObject({ nodeId: "taskCreated", kind: "event" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("no recognised prop → null", () => {
|
|
54
|
+
expect(resolveRef({})).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { scanScopeUsages } from "../src/scan";
|
|
3
|
+
|
|
4
|
+
const FIXTURES = `${import.meta.dir}/fixtures`;
|
|
5
|
+
|
|
6
|
+
function scan() {
|
|
7
|
+
return scanScopeUsages({
|
|
8
|
+
globs: [`${FIXTURES}/**/*.{ts,tsx}`],
|
|
9
|
+
workspaceRoot: FIXTURES,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("scanScopeUsages", () => {
|
|
14
|
+
test("resolves wrapper-hook scope (inline + via-variable)", () => {
|
|
15
|
+
const { componentEdges } = scan();
|
|
16
|
+
const find = (file: string, target: string, usage: string) =>
|
|
17
|
+
componentEdges.find((e) => e.source === file && e.target === target && e.usage === usage);
|
|
18
|
+
|
|
19
|
+
// Inline: scope.useQuery().contentTopic.getAll() with useContentScope -> workspace
|
|
20
|
+
const topicQuery = find("pkg/list-page.tsx", "contentTopic", "query");
|
|
21
|
+
expect(topicQuery).toBeDefined();
|
|
22
|
+
expect(topicQuery!.scope).toBe("workspace");
|
|
23
|
+
expect(topicQuery!.methods).toContain("getAll");
|
|
24
|
+
|
|
25
|
+
// Via variable: const commands = scope.useMutation(); commands.contentTopic.rename()
|
|
26
|
+
const topicMutation = find("pkg/list-page.tsx", "contentTopic", "mutation");
|
|
27
|
+
expect(topicMutation).toBeDefined();
|
|
28
|
+
expect(topicMutation!.scope).toBe("workspace");
|
|
29
|
+
expect(topicMutation!.methods).toContain("rename");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("resolves direct usePlatformScope(ctx, scope) call", () => {
|
|
33
|
+
const { componentEdges } = scan();
|
|
34
|
+
const me = componentEdges.find(
|
|
35
|
+
(e) => e.source === "pkg/direct.tsx" && e.target === "userAccounts",
|
|
36
|
+
);
|
|
37
|
+
expect(me).toBeDefined();
|
|
38
|
+
expect(me!.usage).toBe("query");
|
|
39
|
+
expect(me!.scope).toBe("user");
|
|
40
|
+
expect(me!.methods).toContain("getMe");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("emits one component per scanned file with usages", () => {
|
|
44
|
+
const { components } = scan();
|
|
45
|
+
const ids = components.map((c) => c.id).sort();
|
|
46
|
+
expect(ids).toContain("pkg/list-page.tsx");
|
|
47
|
+
expect(ids).toContain("pkg/direct.tsx");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("page() re-keys a page component's edges onto the route path", () => {
|
|
51
|
+
const { componentEdges, components } = scan();
|
|
52
|
+
// routes.ts: page("/detail/", DetailPage), DetailPage = lazy(import("./detail-page")).
|
|
53
|
+
// detail-page.tsx edges get re-keyed from the file id onto the normalized path.
|
|
54
|
+
const q = componentEdges.find((e) => e.source === "/detail" && e.target === "userAccounts" && e.usage === "query");
|
|
55
|
+
const m = componentEdges.find((e) => e.source === "/detail" && e.target === "userAccounts" && e.usage === "mutation");
|
|
56
|
+
expect(q).toBeDefined();
|
|
57
|
+
expect(m).toBeDefined();
|
|
58
|
+
// no edge keeps the original file id, and the orphan file node is dropped.
|
|
59
|
+
expect(componentEdges.some((e) => e.source === "pkg/detail-page.tsx")).toBe(false);
|
|
60
|
+
expect(components.some((c) => c.id === "pkg/detail-page.tsx")).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("aggregates methods per (element, usage, scope) edge", () => {
|
|
64
|
+
const { componentEdges } = scan();
|
|
65
|
+
const contentQuery = componentEdges.find(
|
|
66
|
+
(e) => e.source === "pkg/list-page.tsx" && e.target === "content" && e.usage === "query",
|
|
67
|
+
);
|
|
68
|
+
expect(contentQuery).toBeDefined();
|
|
69
|
+
expect(contentQuery!.methods).toEqual(["getAll"]);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildSubgraph, neighborsOf } from "../src/app/subgraph";
|
|
3
|
+
import type { MapEdge, MapJson } from "../src/types";
|
|
4
|
+
|
|
5
|
+
function el(id: string, kind: any = "unknown") {
|
|
6
|
+
return { id, name: id, kind, spaceId: "s" };
|
|
7
|
+
}
|
|
8
|
+
function edge(source: string, target: string, kind: MapEdge["kind"] = "mutates"): MapEdge {
|
|
9
|
+
return { id: `${source}->${target}`, source, target, kind, crossContext: false };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Graph: A(command) --mutates--> E(event) <--listens-- L(listener) --mutates--> E2(event) <--handles-- V(view)
|
|
13
|
+
// X is unrelated.
|
|
14
|
+
const map: MapJson = {
|
|
15
|
+
spaces: [{ id: "s", name: "s" }],
|
|
16
|
+
elements: [el("A", "command"), el("E", "event"), el("L", "listener"), el("E2", "event"), el("V", "view"), el("X")],
|
|
17
|
+
edges: [edge("A", "E"), edge("L", "E", "listens"), edge("L", "E2"), edge("V", "E2", "handles")],
|
|
18
|
+
components: [],
|
|
19
|
+
componentEdges: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("buildSubgraph", () => {
|
|
23
|
+
test("referenced + 1-hop + connecting path (undirected through plumbing)", () => {
|
|
24
|
+
const sub = buildSubgraph({ map, referenced: ["A", "V"] });
|
|
25
|
+
// A..V connects via A-E-L-E2-V (undirected) — all included; X excluded.
|
|
26
|
+
expect([...sub.nodeIds].sort()).toEqual(["A", "E", "E2", "L", "V"]);
|
|
27
|
+
expect(sub.referencedIds).toEqual(["A", "V"]);
|
|
28
|
+
expect(sub.edges.length).toBe(4);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("path cap drops too-long connectors", () => {
|
|
32
|
+
const sub = buildSubgraph({ map, referenced: ["A", "V"], maxPath: 2 });
|
|
33
|
+
// shortest A-V path is 4 hops > 2 → only referenced + their 1-hop neighbours.
|
|
34
|
+
expect(sub.nodeIds.has("A")).toBe(true);
|
|
35
|
+
expect(sub.nodeIds.has("E")).toBe(true); // 1-hop of A
|
|
36
|
+
expect(sub.nodeIds.has("E2")).toBe(true); // 1-hop of V
|
|
37
|
+
expect(sub.nodeIds.has("L")).toBe(false); // only reachable via the long path
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("unresolved references are filtered", () => {
|
|
41
|
+
const sub = buildSubgraph({ map, referenced: ["A", "ghost"] });
|
|
42
|
+
expect(sub.referencedIds).toEqual(["A"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("expand adds a node and its neighbours", () => {
|
|
46
|
+
const sub = buildSubgraph({ map, referenced: ["A"], expanded: ["V"] });
|
|
47
|
+
expect(sub.nodeIds.has("V")).toBe(true);
|
|
48
|
+
expect(sub.nodeIds.has("E2")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("neighborsOf is undirected", () => {
|
|
52
|
+
expect(neighborsOf(map, "E").sort()).toEqual(["A", "L"]);
|
|
53
|
+
});
|
|
54
|
+
});
|