@mrciphersmith/keryx 0.2.76 → 0.2.78

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.
Files changed (51) hide show
  1. package/README.md +15 -10
  2. package/dist/cli.js +11401 -9120
  3. package/dist/proxy-worker.js +12 -12
  4. package/package.json +1 -1
  5. package/src/gdgraph/build.ts +12 -0
  6. package/src/gdgraph/query.ts +51 -1
  7. package/src/gdgraph/types.ts +77 -0
  8. package/src/gdgraph/wiki-layer-no-git.test.ts +73 -0
  9. package/src/gdgraph/wiki-layer.test.ts +211 -0
  10. package/src/gdgraph/wiki-layer.ts +213 -0
  11. package/src/gdskills/bundled/rules/core/api-contracts.mdc +6 -4
  12. package/src/gdskills/bundled/rules/core/model-selection.mdc +4 -5
  13. package/src/gdskills/bundled/skills/orchestration/feature-analyzer/SKILL.codex.md +4 -38
  14. package/src/gdskills/bundled/skills/orchestration/feature-analyzer/SKILL.cursor.md +4 -38
  15. package/src/gdskills/bundled/skills/orchestration/feature-analyzer/SKILL.md +4 -38
  16. package/src/gdskills/bundled/skills/orchestration/feature-analyzer/SKILL.opencode.md +4 -38
  17. package/src/gdskills/bundled/skills/orchestration/feature-analyzer/SKILL.zed.md +4 -38
  18. package/src/gdskills/bundled/skills/orchestration/feature-dev/SKILL.codex.md +1 -3
  19. package/src/gdskills/bundled/skills/orchestration/feature-dev/SKILL.cursor.md +1 -3
  20. package/src/gdskills/bundled/skills/orchestration/feature-dev/SKILL.md +1 -3
  21. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.codex.md +35 -34
  22. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.cursor.md +35 -34
  23. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.md +35 -34
  24. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.opencode.md +35 -34
  25. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/SKILL.zed.md +35 -34
  26. package/src/gdskills/bundled/skills/orchestration/job-orchestrator/input-contract.schema.json +1 -1
  27. package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.codex.md +2 -4
  28. package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.cursor.md +2 -4
  29. package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.md +2 -4
  30. package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.opencode.md +2 -4
  31. package/src/gdskills/bundled/skills/orchestration/task-implementer/SKILL.zed.md +2 -4
  32. package/src/gdskills/bundled/skills/planning/autodoc-orchestrator/SKILL.md +10 -6
  33. package/src/gdskills/bundled/skills/planning/interview/SKILL.codex.md +1 -1
  34. package/src/gdskills/bundled/skills/planning/interview/SKILL.cursor.md +1 -1
  35. package/src/gdskills/bundled/skills/planning/interview/SKILL.md +1 -1
  36. package/src/gdskills/bundled/skills/planning/interviewer/SKILL.codex.md +1 -1
  37. package/src/gdskills/bundled/skills/planning/interviewer/SKILL.cursor.md +1 -1
  38. package/src/gdskills/bundled/skills/planning/interviewer/SKILL.md +1 -1
  39. package/src/gdskills/bundled/skills/platform/agent-entrypoint-distiller/SKILL.md +4 -0
  40. package/src/gdskills/bundled/skills/review/review-architecture/SKILL.md +1 -2
  41. package/src/gdskills/bundled/skills/review/review-backend/SKILL.md +2 -2
  42. package/src/gdskills/bundled/skills/review/review-clean-code/SKILL.md +1 -2
  43. package/src/gdskills/bundled/skills/review/review-core-boundaries/SKILL.md +4 -0
  44. package/src/gdskills/bundled/skills/review/review-flow-graph/SKILL.md +4 -0
  45. package/src/gdskills/bundled/skills/review/review-frontend-conventions/SKILL.md +4 -0
  46. package/src/gdskills/bundled/skills/review/review-highload/SKILL.md +1 -2
  47. package/src/gdskills/bundled/skills/review/review-orchestrator/SKILL.md +8 -13
  48. package/src/gdskills/bundled/skills/review/review-pr-feedback/SKILL.md +2 -3
  49. package/src/gdskills/bundled/skills/review/review-style/SKILL.md +2 -3
  50. package/src/gdskills/bundled/skills/review/review-testing-practices/SKILL.md +5 -0
  51. package/src/gdskills/bundled/skills/review/review-verifier/SKILL.md +1 -2
@@ -124,23 +124,23 @@ async function createAllowlistProxy(opts) {
124
124
  };
125
125
  const mitmServers = new Map;
126
126
  const mitmPortFor = async (hostname, targetPort, ca) => {
127
- const host2 = normalizeHost(hostname);
128
- const key = `${host2}:${targetPort}`;
127
+ const host = normalizeHost(hostname);
128
+ const key = `${host}:${targetPort}`;
129
129
  const hit = mitmServers.get(key);
130
130
  if (hit)
131
131
  return hit.port;
132
- const leaf = await ca.issueLeaf(host2);
133
- const server2 = https.createServer({ key: leaf.keyPem, cert: leaf.certPem }, makeMitmHandler(host2, targetPort));
134
- await new Promise((resolve) => server2.listen(0, "127.0.0.1", () => resolve()));
135
- const addr2 = server2.address();
136
- const port2 = addr2 && typeof addr2 === "object" ? addr2.port : 0;
137
- mitmServers.set(key, { server: server2, port: port2 });
138
- return port2;
132
+ const leaf = await ca.issueLeaf(host);
133
+ const server = https.createServer({ key: leaf.keyPem, cert: leaf.certPem }, makeMitmHandler(host, targetPort));
134
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
135
+ const addr = server.address();
136
+ const port = addr && typeof addr === "object" ? addr.port : 0;
137
+ mitmServers.set(key, { server, port });
138
+ return port;
139
139
  };
140
140
  server.on("connect", (req, clientSocket, head) => {
141
141
  const [reqHost, reqPort] = (req.url ?? "").split(":");
142
142
  const hostname = reqHost ?? "";
143
- const port2 = Number(reqPort) || 443;
143
+ const port = Number(reqPort) || 443;
144
144
  if (!decide({ host: hostname, allowed: matchesAllowlist(hostname, allowed), kind: "connect" })) {
145
145
  clientSocket.write(`HTTP/1.1 403 Forbidden\r
146
146
  \r
@@ -152,7 +152,7 @@ async function createAllowlistProxy(opts) {
152
152
  if (ca) {
153
153
  (async () => {
154
154
  try {
155
- const terminatorPort = await mitmPortFor(hostname, port2, ca);
155
+ const terminatorPort = await mitmPortFor(hostname, port, ca);
156
156
  clientSocket.write(`HTTP/1.1 200 Connection Established\r
157
157
  \r
158
158
  `);
@@ -173,7 +173,7 @@ async function createAllowlistProxy(opts) {
173
173
  })();
174
174
  return;
175
175
  }
176
- const upstream = net.connect(port2, hostname, () => {
176
+ const upstream = net.connect(port, hostname, () => {
177
177
  clientSocket.write(`HTTP/1.1 200 Connection Established\r
178
178
  \r
179
179
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrciphersmith/keryx",
3
- "version": "0.2.76",
3
+ "version": "0.2.78",
4
4
  "description": "Version-controlled project context for AI coding agents: code graph, architecture wiki, project memory, relevant tests, quality signals, and task flows.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -159,6 +159,18 @@ export async function buildGraph(projectRoot: string): Promise<BuildResult> {
159
159
  // Enrichment module or seam unavailable ⇒ file-level graph only.
160
160
  }
161
161
 
162
+ // LWG wiki layer (flow 223): the same additive shape as the symbol layer
163
+ // above — after the unchanged file-level build, into its OWN storage files,
164
+ // dynamically imported and defensively wrapped so a failure degrades to
165
+ // "no wiki layer" rather than a failed graph build. `nodes.jsonl` and
166
+ // `edges.jsonl` written above are never revisited (AC13).
167
+ try {
168
+ const { enrichBuildWithWikiLayer } = await import("./wiki-layer");
169
+ await enrichBuildWithWikiLayer({ projectRoot, graph, fileRecords });
170
+ } catch {
171
+ // Wiki absent, unreadable, or the layer module unavailable ⇒ no layer.
172
+ }
173
+
162
174
  return {
163
175
  nodes: nodes.length,
164
176
  edges: edges.length,
@@ -1,6 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import type { CallEdge, GraphData, GraphEdge, GraphNode, SymbolNode } from "./types";
3
+ import type {
4
+ CallEdge,
5
+ DescribesEdge,
6
+ GraphData,
7
+ GraphEdge,
8
+ GraphNode,
9
+ SymbolNode,
10
+ WikiPageNode,
11
+ } from "./types";
4
12
  import { resolveGraphTarget } from "./target";
5
13
 
6
14
  export async function loadGraph(projectRoot: string): Promise<GraphData> {
@@ -11,6 +19,11 @@ export async function loadGraph(projectRoot: string): Promise<GraphData> {
11
19
  // (never an error — mirrors `readJsonl` tolerance). File-level graph unchanged.
12
20
  const symbols = await readJsonl<SymbolNode>(path.join(storageDir, "symbols.jsonl"));
13
21
  const calls = await readJsonl<CallEdge>(path.join(storageDir, "calls.jsonl"));
22
+ // LWG wiki layer (flow 223): same contract as the symbol layer above —
23
+ // loaded only if present, absent ⇒ omitted, never an error. A graph built
24
+ // before this layer existed therefore loads byte-for-byte as it always did.
25
+ const wikiPages = await readJsonl<WikiPageNode>(path.join(storageDir, "wiki-pages.jsonl"));
26
+ const describes = await readJsonl<DescribesEdge>(path.join(storageDir, "describes.jsonl"));
14
27
  const graph: GraphData = { nodes, edges };
15
28
  if (symbols.length > 0) {
16
29
  graph.symbols = symbols;
@@ -18,9 +31,46 @@ export async function loadGraph(projectRoot: string): Promise<GraphData> {
18
31
  if (calls.length > 0) {
19
32
  graph.calls = calls;
20
33
  }
34
+ if (wikiPages.length > 0) {
35
+ graph.wikiPages = wikiPages;
36
+ }
37
+ if (describes.length > 0) {
38
+ graph.describes = describes;
39
+ }
21
40
  return graph;
22
41
  }
23
42
 
43
+ /**
44
+ * Reverse the `describes` layer: which wiki pages document this file?
45
+ * (LWG-1, flow 223 AC3.)
46
+ *
47
+ * The layer stores only page → file, so this is the index every caller would
48
+ * otherwise rebuild. An absent layer yields an empty array — the honest
49
+ * answer is "no information", and a caller must not read that as "this file
50
+ * is undocumented"; only a layer that IS present can support that claim.
51
+ */
52
+ export function getPagesDescribing(graph: GraphData, filePath: string): string[] {
53
+ const target = filePath.replace(/^\.\//, "");
54
+ const pages = new Set<string>();
55
+ for (const edge of graph.describes ?? []) {
56
+ if (edge.to === target) {
57
+ pages.add(edge.from);
58
+ }
59
+ }
60
+ return [...pages].sort();
61
+ }
62
+
63
+ /** Every file the given page documents (LWG-1). Empty when undecidable. */
64
+ export function getFilesDescribedBy(graph: GraphData, pageId: string): string[] {
65
+ const files = new Set<string>();
66
+ for (const edge of graph.describes ?? []) {
67
+ if (edge.from === pageId) {
68
+ files.add(edge.to);
69
+ }
70
+ }
71
+ return [...files].sort();
72
+ }
73
+
24
74
  export function getOrphans(graph: GraphData): string[] {
25
75
  const inbound = new Set(
26
76
  graph.edges.filter((edge) => edge.kind !== "unresolved").map((edge) => edge.to),
@@ -56,6 +56,12 @@ export type GraphData = {
56
56
  // these fields, keeping the legacy graph shape backward-compatible.
57
57
  symbols?: SymbolNode[];
58
58
  calls?: CallEdge[];
59
+ // LWG wiki layer (flow 223) — present only when `storage/wiki-pages.jsonl` /
60
+ // `storage/describes.jsonl` exist. Same additive contract as the symbol
61
+ // layer: missing ⇒ omitted, never an error, and file-level consumers that
62
+ // ignore these fields keep the legacy graph shape.
63
+ wikiPages?: WikiPageNode[];
64
+ describes?: DescribesEdge[];
59
65
  };
60
66
 
61
67
  // --- B1 symbol layer types (additive; only materialized in symbols.jsonl /
@@ -95,3 +101,74 @@ export type SymbolLayer = {
95
101
  symbols: SymbolNode[];
96
102
  calls: CallEdge[];
97
103
  };
104
+
105
+ // --- LWG wiki layer (flow 223, phase 0; additive, materialized only in
106
+ // `storage/wiki-pages.jsonl` / `storage/describes.jsonl`). ---
107
+ //
108
+ // WHY A SEPARATE LAYER rather than new `kind`s inside `nodes.jsonl` /
109
+ // `edges.jsonl`, which the package specification originally called for:
110
+ // five production call sites treat "every node that is not `asset`" as a
111
+ // source file — `validModuleNames` and `collectGraphWikiCandidates`
112
+ // (`src/wiki/service.ts:365,412`), `computeModuleKeyFiles`
113
+ // (`src/wiki/collect.ts:97`) and `src/commands/update.ts:869`. A page node
114
+ // in `nodes.jsonl` would be grouped by `moduleNameFromProjectPath` into a
115
+ // fabricated module (`.metaproject/wiki/components`) and would corrupt the
116
+ // very module set `wikiPruneOrphans` and `src/sac/lifecycle-flag.ts` use to
117
+ // decide what is orphaned. Keeping the layer separate also preserves the
118
+ // build's existing rule that additive layers never rewrite the legacy
119
+ // artifacts (see `build.ts`'s symbol-layer enrichment).
120
+
121
+ /**
122
+ * Where a `describes` relation came from, highest precedence first
123
+ * (specification §3.3). An explicit `Describes:` frontmatter list REPLACES
124
+ * the derived set rather than adding to it — a human who names the files a
125
+ * page covers is correcting the derivation, not supplementing it.
126
+ */
127
+ export type DescribesOrigin = "frontmatter" | "related-code" | "key-files";
128
+
129
+ export type WikiPageNode = {
130
+ /** "wiki:<wiki-relative path>", e.g. "wiki:components/src-ctx.md". */
131
+ id: string;
132
+ /** Wiki-relative path, e.g. "components/src-ctx.md". */
133
+ path: string;
134
+ title: string;
135
+ /** `WikiPageType` as a plain string; the wiki owns that union. */
136
+ pageType: string;
137
+ status: string | null;
138
+ version: string | null;
139
+ /**
140
+ * True when no describe pattern resolved to a known file. Such a page
141
+ * cannot be scored for freshness — which is NOT the same as being fresh,
142
+ * and NOT the same as being orphaned (specification §4.4.1).
143
+ */
144
+ undecidable: boolean;
145
+ };
146
+
147
+ export type DescribesEdge = {
148
+ id: string;
149
+ /** `WikiPageNode.id`. */
150
+ from: string;
151
+ /** A file `GraphNode.path` — the layer only ever points at known files. */
152
+ to: string;
153
+ /** The pattern that produced this edge, as written or derived. */
154
+ pattern: string;
155
+ origin: DescribesOrigin;
156
+ };
157
+
158
+ export type WikiLayer = {
159
+ pages: WikiPageNode[];
160
+ describes: DescribesEdge[];
161
+ };
162
+
163
+ /**
164
+ * Per-file content fingerprint (LWG-2), written to
165
+ * `storage/build-manifest.json`. Kept out of `GraphNode` so `nodes.jsonl`
166
+ * stays byte-stable for its existing consumers; the incremental rebuild
167
+ * (LWG-3, phase 4) is the intended reader.
168
+ */
169
+ export type FileFingerprint = {
170
+ path: string;
171
+ /** sha256 of the file's content at build time. */
172
+ contentHash: string;
173
+ mtimeMs: number;
174
+ };
@@ -0,0 +1,73 @@
1
+ // Flow 223 AC11: nothing phase 0 adds may depend on git.
2
+ //
3
+ // Different projects treat `.metaproject/` differently, and
4
+ // `src/commands/init.no-git.test.ts` already pins "no git at all" as a
5
+ // supported state. The describe edge, the wiki layer, the build manifest and
6
+ // page provenance must therefore all work on a bare directory — this suite is
7
+ // the guard that keeps a future `git` call from creeping into that path.
8
+
9
+ import { expect, test } from "bun:test";
10
+ import { mkdtemp, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import { computeVerifiedScope, parseProvenance, writeProvenance } from "../wiki/provenance";
14
+ import { buildGraph } from "./build";
15
+ import { getFilesDescribedBy, loadGraph } from "./query";
16
+
17
+ async function bareProject(): Promise<string> {
18
+ const root = await mkdtemp(path.join(tmpdir(), "lwg-nogit-"));
19
+ await mkdir(path.join(root, "src", "ctx"), { recursive: true });
20
+ await writeFile(path.join(root, "src", "ctx", "index.ts"), 'export * from "./run";\n');
21
+ await writeFile(path.join(root, "src", "ctx", "run.ts"), "export const run = () => 1;\n");
22
+ await mkdir(path.join(root, ".metaproject", "wiki", "components"), { recursive: true });
23
+ await writeFile(
24
+ path.join(root, ".metaproject", "wiki", "components", "src-ctx.md"),
25
+ "# src/ctx\nVersion: 1.0.0\nType: component\nStatus: accepted\nDescribes:\n - src/ctx/**\n",
26
+ );
27
+ return root;
28
+ }
29
+
30
+ test("the whole phase-0 path works with no .git directory anywhere", async () => {
31
+ const root = await bareProject();
32
+
33
+ // Two builds: the first writes nodes.jsonl, the second sees a module set.
34
+ await buildGraph(root);
35
+ await buildGraph(root);
36
+
37
+ const graph = await loadGraph(root);
38
+ expect(graph.wikiPages?.[0]?.id).toBe("wiki:components/src-ctx.md");
39
+ expect(getFilesDescribedBy(graph, "wiki:components/src-ctx.md")).toEqual([
40
+ "src/ctx/index.ts",
41
+ "src/ctx/run.ts",
42
+ ]);
43
+
44
+ // VerifiedScope is the git-free freshness path; it must compute here.
45
+ const scope = await computeVerifiedScope(root, ["src/ctx/index.ts", "src/ctx/run.ts"], graph);
46
+ expect(scope).toMatch(/^sha256:[0-9a-f]{64}$/);
47
+
48
+ const pagePath = path.join(root, ".metaproject", "wiki", "components", "src-ctx.md");
49
+ const stamped = writeProvenance(await readFile(pagePath, "utf8"), { verifiedScope: scope });
50
+ await writeFile(pagePath, stamped);
51
+ expect(parseProvenance(await readFile(pagePath, "utf8")).verifiedScope).toBe(scope);
52
+
53
+ // And no `.git` was created along the way.
54
+ expect(await readdir(root)).not.toContain(".git");
55
+ });
56
+
57
+ test("a page carrying VerifiedAt but no reachable git history still resolves", async () => {
58
+ const root = await bareProject();
59
+ const pagePath = path.join(root, ".metaproject", "wiki", "components", "src-ctx.md");
60
+ await writeFile(
61
+ pagePath,
62
+ writeProvenance(await readFile(pagePath, "utf8"), { verifiedAt: "a".repeat(40) }),
63
+ );
64
+
65
+ await buildGraph(root);
66
+ await buildGraph(root);
67
+
68
+ // The sha is parsed and kept; deciding it is unreachable is phase 1's job,
69
+ // and must not become a build-time failure here (specification §4.1 path 2).
70
+ expect(parseProvenance(await readFile(pagePath, "utf8")).verifiedAt).toBe("a".repeat(40));
71
+ const graph = await loadGraph(root);
72
+ expect(graph.wikiPages?.[0]?.undecidable).toBe(false);
73
+ });
@@ -0,0 +1,211 @@
1
+ // LWG wiki layer (flow 223, phase 0): AC1, AC5, AC6, AC7, AC10, AC13.
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+ import { buildGraph } from "./build";
8
+ import { getFilesDescribedBy, getPagesDescribing, loadGraph } from "./query";
9
+ import type { GraphData } from "./types";
10
+ import { buildWikiLayer, computeFingerprints, wikiPageId } from "./wiki-layer";
11
+
12
+ const GRAPH: GraphData = {
13
+ nodes: [
14
+ { id: "src/ctx/index.ts", kind: "file", path: "src/ctx/index.ts", language: "typescript" },
15
+ { id: "src/ctx/run.ts", kind: "file", path: "src/ctx/run.ts", language: "typescript" },
16
+ { id: "logo.png", kind: "asset", path: "logo.png", language: "asset" },
17
+ ],
18
+ edges: [],
19
+ };
20
+
21
+ function page(relativePath: string, overrides: Partial<{ title: string; pageType: string }> = {}) {
22
+ return {
23
+ relativePath,
24
+ title: overrides.title ?? relativePath,
25
+ pageType: overrides.pageType ?? "component",
26
+ status: "accepted" as string | null,
27
+ version: "1.0.0" as string | null,
28
+ };
29
+ }
30
+
31
+ describe("buildWikiLayer", () => {
32
+ test("AC1: emits a page node per page and describes edges for resolved paths", () => {
33
+ const layer = buildWikiLayer({
34
+ projectRoot: "/x",
35
+ graph: GRAPH,
36
+ validModules: new Set(["src/ctx"]),
37
+ pages: [page("components/src-ctx.md")],
38
+ pageContents: new Map([
39
+ ["components/src-ctx.md", "Describes:\n - src/ctx/index.ts\n - src/ctx/run.ts\n"],
40
+ ]),
41
+ });
42
+
43
+ expect(layer.pages).toHaveLength(1);
44
+ expect(layer.pages[0]?.id).toBe("wiki:components/src-ctx.md");
45
+ expect(layer.pages[0]?.undecidable).toBe(false);
46
+ expect(layer.describes.map((edge) => edge.to)).toEqual(["src/ctx/index.ts", "src/ctx/run.ts"]);
47
+ expect(layer.describes.every((edge) => edge.origin === "frontmatter")).toBe(true);
48
+ });
49
+
50
+ test("AC6: an unbuilt graph yields an EMPTY layer, not a layer of orphans", () => {
51
+ const layer = buildWikiLayer({
52
+ projectRoot: "/x",
53
+ graph: GRAPH,
54
+ validModules: undefined,
55
+ pages: [page("components/src-ctx.md")],
56
+ pageContents: new Map([["components/src-ctx.md", "Describes:\n - src/ctx/index.ts\n"]]),
57
+ });
58
+
59
+ // The dangerous alternative is one page node per page marked undecidable:
60
+ // that reads downstream as "every page describes nothing", i.e. everything
61
+ // is orphaned. Absent information must stay absent.
62
+ expect(layer).toEqual({ pages: [], describes: [] });
63
+ });
64
+
65
+ test("AC5: the module set drives the layer — a stubbed set changes the output", () => {
66
+ const withModules = buildWikiLayer({
67
+ projectRoot: "/x",
68
+ graph: GRAPH,
69
+ validModules: new Set(["src/ctx"]),
70
+ pages: [page("components/src-ctx.md")],
71
+ pageContents: new Map([["components/src-ctx.md", "# Page\n"]]),
72
+ });
73
+ const withoutModules = buildWikiLayer({
74
+ projectRoot: "/x",
75
+ graph: GRAPH,
76
+ validModules: undefined,
77
+ pages: [page("components/src-ctx.md")],
78
+ pageContents: new Map([["components/src-ctx.md", "# Page\n"]]),
79
+ });
80
+ expect(withModules.pages.length).not.toBe(withoutModules.pages.length);
81
+ });
82
+
83
+ test("AC10: a page describing nothing is undecidable and emits no edges", () => {
84
+ const layer = buildWikiLayer({
85
+ projectRoot: "/x",
86
+ graph: GRAPH,
87
+ validModules: new Set(["src/ctx"]),
88
+ pages: [page("architecture/overview.md", { pageType: "architecture" })],
89
+ pageContents: new Map([["architecture/overview.md", "# Overview\nStatus: accepted\n"]]),
90
+ });
91
+
92
+ expect(layer.pages[0]?.undecidable).toBe(true);
93
+ expect(layer.describes).toEqual([]);
94
+ });
95
+
96
+ test("asset nodes are never describable targets", () => {
97
+ const layer = buildWikiLayer({
98
+ projectRoot: "/x",
99
+ graph: GRAPH,
100
+ validModules: new Set(["src/ctx"]),
101
+ pages: [page("components/x.md")],
102
+ pageContents: new Map([["components/x.md", "Describes:\n - logo.png\n"]]),
103
+ });
104
+ expect(layer.describes).toEqual([]);
105
+ expect(layer.pages[0]?.undecidable).toBe(true);
106
+ });
107
+ });
108
+
109
+ describe("computeFingerprints (AC7)", () => {
110
+ test("hashes are content-derived, stable and sorted by path", () => {
111
+ const records = [
112
+ { path: "b.ts", content: "export const b = 1;\n" },
113
+ { path: "a.ts", content: "export const a = 1;\n" },
114
+ ];
115
+ const first = computeFingerprints(records, new Map([["a.ts", 5]]));
116
+ const second = computeFingerprints(records, new Map([["a.ts", 5]]));
117
+
118
+ expect(first.map((f) => f.path)).toEqual(["a.ts", "b.ts"]);
119
+ expect(first).toEqual(second);
120
+ expect(first[0]?.contentHash).toMatch(/^[0-9a-f]{64}$/);
121
+ expect(first[0]?.contentHash).not.toBe(first[1]?.contentHash);
122
+ // A file whose mtime could not be read is recorded as 0, not dropped.
123
+ expect(first[1]?.mtimeMs).toBe(0);
124
+ });
125
+ });
126
+
127
+ describe("reverse queries (AC3)", () => {
128
+ const graph: GraphData = {
129
+ ...GRAPH,
130
+ describes: [
131
+ { id: "d1", from: wikiPageId("components/a.md"), to: "src/ctx/index.ts", pattern: "src/ctx", origin: "key-files" },
132
+ { id: "d2", from: wikiPageId("components/b.md"), to: "src/ctx/index.ts", pattern: "src/ctx/index.ts", origin: "frontmatter" },
133
+ { id: "d3", from: wikiPageId("components/b.md"), to: "src/ctx/run.ts", pattern: "src/ctx/run.ts", origin: "frontmatter" },
134
+ ],
135
+ };
136
+
137
+ test("a file covered by two pages returns both, and nothing else", () => {
138
+ expect(getPagesDescribing(graph, "src/ctx/index.ts")).toEqual([
139
+ "wiki:components/a.md",
140
+ "wiki:components/b.md",
141
+ ]);
142
+ expect(getPagesDescribing(graph, "src/ctx/run.ts")).toEqual(["wiki:components/b.md"]);
143
+ });
144
+
145
+ test("the forward direction is available from the same layer", () => {
146
+ expect(getFilesDescribedBy(graph, wikiPageId("components/b.md"))).toEqual([
147
+ "src/ctx/index.ts",
148
+ "src/ctx/run.ts",
149
+ ]);
150
+ });
151
+
152
+ test("no layer means no information, and an empty answer says so honestly", () => {
153
+ expect(getPagesDescribing({ nodes: [], edges: [] }, "src/ctx/index.ts")).toEqual([]);
154
+ });
155
+ });
156
+
157
+ describe("end-to-end build (AC1, AC7, AC13)", () => {
158
+ async function fixture(): Promise<string> {
159
+ const root = await mkdtemp(path.join(tmpdir(), "lwg-build-"));
160
+ await mkdir(path.join(root, "src", "ctx"), { recursive: true });
161
+ await writeFile(path.join(root, "src", "ctx", "index.ts"), 'export * from "./run";\n');
162
+ await writeFile(path.join(root, "src", "ctx", "run.ts"), "export const run = () => 1;\n");
163
+ return root;
164
+ }
165
+
166
+ test("layer files appear, and nodes.jsonl/edges.jsonl stay byte-identical (AC13)", async () => {
167
+ const root = await fixture();
168
+ const storage = path.join(root, ".metaproject", "data", "gdgraph", "storage");
169
+
170
+ // Build with no wiki at all: the layer cannot run.
171
+ await buildGraph(root);
172
+ const nodesBefore = await readFile(path.join(storage, "nodes.jsonl"), "utf8");
173
+ const edgesBefore = await readFile(path.join(storage, "edges.jsonl"), "utf8");
174
+
175
+ // Now add a wiki page and rebuild: the layer runs.
176
+ await mkdir(path.join(root, ".metaproject", "wiki", "components"), { recursive: true });
177
+ await writeFile(
178
+ path.join(root, ".metaproject", "wiki", "components", "src-ctx.md"),
179
+ "# src/ctx\nVersion: 1.0.0\nType: component\nStatus: accepted\nDescribes:\n - src/ctx/run.ts\n",
180
+ );
181
+ await buildGraph(root);
182
+
183
+ const nodesAfter = await readFile(path.join(storage, "nodes.jsonl"), "utf8");
184
+ const edgesAfter = await readFile(path.join(storage, "edges.jsonl"), "utf8");
185
+ // The whole reason the layer is separate: legacy artifacts unchanged.
186
+ expect(nodesAfter).toBe(nodesBefore);
187
+ expect(edgesAfter).toBe(edgesBefore);
188
+
189
+ const graph = await loadGraph(root);
190
+ expect(graph.wikiPages?.map((p) => p.id)).toEqual(["wiki:components/src-ctx.md"]);
191
+ expect(getFilesDescribedBy(graph, "wiki:components/src-ctx.md")).toEqual(["src/ctx/run.ts"]);
192
+
193
+ const manifest = JSON.parse(await readFile(path.join(storage, "build-manifest.json"), "utf8"));
194
+ expect(manifest.files.map((f: { path: string }) => f.path)).toEqual([
195
+ "src/ctx/index.ts",
196
+ "src/ctx/run.ts",
197
+ ]);
198
+ expect(manifest.files[0].contentHash).toMatch(/^[0-9a-f]{64}$/);
199
+ expect(manifest.files[0].mtimeMs).toBeGreaterThan(0);
200
+ });
201
+
202
+ test("AC2: a graph with no layer files loads with the fields simply absent", async () => {
203
+ const root = await fixture();
204
+ await buildGraph(root);
205
+ // No wiki directory was ever created, so no layer files exist.
206
+ const graph = await loadGraph(root);
207
+ expect(graph.wikiPages).toBeUndefined();
208
+ expect(graph.describes).toBeUndefined();
209
+ expect(graph.nodes.length).toBeGreaterThan(0);
210
+ });
211
+ });