@afokapu/atdd-bun 0.5.0 → 0.6.1

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 (66) hide show
  1. package/README.md +30 -0
  2. package/conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml +27 -0
  3. package/conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml +28 -0
  4. package/conventions/atdd-bun.planner/atdd-bun.planner.identity-unique.convention.yaml +27 -0
  5. package/conventions/atdd-bun.planner/atdd-bun.planner.interlocking-participant-resolves.convention.yaml +28 -0
  6. package/conventions/atdd-bun.planner/atdd-bun.planner.parse.convention.yaml +27 -0
  7. package/conventions/atdd-bun.planner/atdd-bun.planner.reference-resolves.convention.yaml +28 -0
  8. package/conventions/atdd-bun.planner/atdd-bun.planner.schema.convention.yaml +30 -0
  9. package/conventions/atdd-bun.planner/atdd-bun.planner.train-wagon-resolves.convention.yaml +26 -0
  10. package/conventions/atdd-bun.topology/atdd-bun.topology.e2e-location.convention.yaml +38 -0
  11. package/conventions/atdd-bun.topology/atdd-bun.topology.feature-source-coverage.convention.yaml +30 -0
  12. package/conventions/atdd-bun.topology/atdd-bun.topology.feature-test-coverage.convention.yaml +29 -0
  13. package/conventions/atdd-bun.topology/atdd-bun.topology.plan-location.convention.yaml +31 -0
  14. package/conventions/atdd-bun.topology/atdd-bun.topology.source-location.convention.yaml +33 -0
  15. package/conventions/atdd-bun.topology/atdd-bun.topology.test-location.convention.yaml +34 -0
  16. package/detectors/atdd_topology/atdd.implementation.yaml +16 -0
  17. package/detectors/atdd_topology/detect.mjs +182 -0
  18. package/detectors/atdd_topology/fixtures/clean/atdd-bun.yaml +2 -0
  19. package/detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts +4 -0
  20. package/detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts +5 -0
  21. package/detectors/atdd_topology/fixtures/clean/implementation/orders/features/place-order/domain/order.ts +4 -0
  22. package/detectors/atdd_topology/fixtures/clean/plan/_journeys/checkout.yaml +4 -0
  23. package/detectors/atdd_topology/fixtures/clean/plan/_trains/_interlockings/checkout.yaml +4 -0
  24. package/detectors/atdd_topology/fixtures/clean/plan/_trains/checkout.yaml +1 -0
  25. package/detectors/atdd_topology/fixtures/clean/plan/orders/E001.yaml +8 -0
  26. package/detectors/atdd_topology/fixtures/clean/plan/orders/_orders.yaml +5 -0
  27. package/detectors/atdd_topology/fixtures/clean/plan/orders/place-order.yaml +3 -0
  28. package/detectors/atdd_topology/fixtures/clean/tests/wagons/orders/features/place-order/unit/order.test.ts +4 -0
  29. package/detectors/atdd_topology/fixtures/dirty/plan/_journeys/checkout.yaml +3 -0
  30. package/detectors/atdd_topology/fixtures/dirty/plan/orders/E001.yaml +4 -0
  31. package/detectors/atdd_topology/fixtures/dirty/plan/orders/_orders.yaml +4 -0
  32. package/detectors/atdd_topology/fixtures/dirty/plan/orders/features/place-order.yaml +3 -0
  33. package/detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/forged.ts +2 -0
  34. package/detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/order.ts +2 -0
  35. package/detectors/atdd_topology/fixtures/dirty/tests/wagons/orders/features/place-order/unit/order.test.ts +4 -0
  36. package/detectors/atdd_traceability_closure/detect.mjs +5 -2
  37. package/detectors/bun_interlocking_binding/checks/interlocking_runtime_executes.mjs +3 -2
  38. package/detectors/bun_interlocking_binding/detect.mjs +6 -5
  39. package/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +4 -3
  40. package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +2 -2
  41. package/detectors/bun_interlocking_infrastructure/_shared/interlocking.mjs +2 -1
  42. package/detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs +3 -2
  43. package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +2 -1
  44. package/detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs +3 -2
  45. package/detectors/bun_tester_discipline_detector/checks/t_acceptance_resolves.mjs +7 -6
  46. package/detectors/planner_docs_capability/atdd.implementation.yaml +7 -0
  47. package/detectors/planner_plan_integrity/atdd.implementation.yaml +1 -0
  48. package/detectors/planner_plan_integrity/detect.mjs +4 -1
  49. package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/_interlockings/checkout.yaml +6 -0
  50. package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/_interlockings/unnamed.yaml +2 -0
  51. package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/dup-a.yaml +7 -0
  52. package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/dup-b.yaml +6 -0
  53. package/detectors/planner_plan_integrity/fixtures/dirty/plan/orders/E001.yaml +5 -0
  54. package/detectors/planner_schema_validation/fixtures/dirty/plan/_trains/missing-description.yaml +5 -0
  55. package/integrity.json +65 -24
  56. package/package.json +1 -1
  57. package/planner-nodes/ENFORCEMENT_SCOPE.yaml +11 -1
  58. package/relationships.yaml +224 -0
  59. package/src/docs-capability.ts +3 -0
  60. package/src/enforce.ts +30 -5
  61. package/src/hooks.ts +2 -1
  62. package/src/index.ts +2 -0
  63. package/src/planner-kernel.ts +4 -2
  64. package/src/planner-schema-validator.ts +12 -10
  65. package/src/planner-validators.ts +16 -15
  66. package/src/topology.ts +40 -0
package/src/hooks.ts CHANGED
@@ -2,6 +2,7 @@ import { chmod, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:f
2
2
  import { existsSync } from "node:fs";
3
3
  import { dirname, join, relative, resolve } from "node:path";
4
4
  import { enforce } from "./enforce";
5
+ import { topologyFor } from "./topology";
5
6
 
6
7
  export type WorktreePolicy = { enabled: boolean; root: string; primary_directory: string; primary_branch: string; require_linked_worktree: boolean };
7
8
  export type HookPolicy = { max_staged_files: number; max_staged_changed_lines: number; max_uncommitted_files: number; max_commits_per_push: number; max_registry_removed_lines: number; registry_paths: string[]; protected_branches: string[]; require_plan_reference: boolean; require_traceability: boolean; worktrees: WorktreePolicy };
@@ -18,7 +19,7 @@ const isRegistry = (cfg: HookPolicy, path: string) => cfg.registry_paths.some(pa
18
19
  const stagedStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "--no-renames"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: path.join("\t") }; });
19
20
 
20
21
  export async function policy(root: string): Promise<HookPolicy> { const file = join(root, "atdd-bun.yaml"); if (!existsSync(file)) return defaultHookPolicy; const data = Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown>; const configuredWorktrees = data?.worktrees && typeof data.worktrees === "object" && !Array.isArray(data.worktrees) ? data.worktrees as Record<string, unknown> : {}; return { ...defaultHookPolicy, ...Object.fromEntries(Object.entries(data ?? {}).filter(([key, value]) => key in defaultHookPolicy && key !== "worktrees" && typeof value === typeof (defaultHookPolicy as any)[key])), protected_branches: strings(data?.protected_branches) ?? defaultHookPolicy.protected_branches, registry_paths: strings(data?.registry_paths) ?? defaultHookPolicy.registry_paths, worktrees: { ...defaultWorktreePolicy, ...Object.fromEntries(Object.entries(configuredWorktrees).filter(([key, value]) => key in defaultWorktreePolicy && typeof value === typeof (defaultWorktreePolicy as any)[key])) } }; }
21
- async function validation(root: string, changed: string[], full: boolean, registryChanged = false) { const profiles: any[] = []; if (registryChanged || changed.some(path => path.startsWith("plan/"))) profiles.push("planner", "traceability"); if (changed.some(path => /\.(?:[cm]?[jt]sx?|html)$/.test(path))) profiles.push("coder", "tester"); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const findings = await enforce({ root, profiles: full ? ["all"] : [...new Set(profiles)] }); return findings.length ? bad(findings.map(f => `${f.rule_id}: ${f.file}`).join("\n")) : { ok: true, message: "validation clean" }; } catch (error) { return bad(`Bun enforcer resolution failed: ${String(error)}`); } }
22
+ async function validation(root: string, changed: string[], full: boolean, registryChanged = false) { const profiles: any[] = [], topology = await topologyFor(root); if (registryChanged || changed.some(path => path.startsWith(`${topology.planRoot}/`)) || changed.includes("atdd-bun.yaml")) profiles.push("planner", "traceability"); if (changed.some(path => /\.(?:[cm]?[jt]sx?|html)$/.test(path))) profiles.push("coder", "tester"); if (changed.includes("atdd-bun.yaml") || changed.some(path => /\.ya?ml$/.test(path))) profiles.push("topology"); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const findings = await enforce({ root, profiles: full ? ["all"] : [...new Set(profiles)] }); return findings.length ? bad(findings.map(f => `${f.rule_id}: ${f.file}`).join("\n")) : { ok: true, message: "validation clean" }; } catch (error) { return bad(`Bun enforcer resolution failed: ${String(error)}`); } }
22
23
 
23
24
  const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
24
25
  async function primaryRoot(root: string): Promise<string | null> { const result = await git(root, ["rev-parse", "--git-common-dir"]); if (result.code) return null; return dirname(resolve(root, result.out)); }
package/src/index.ts CHANGED
@@ -15,3 +15,5 @@ export type { Gap, Journey, JourneyModel, JourneyPath } from "./journey-docs";
15
15
  export { checkIntegrity, checkInstalledPackage, formatIntegrity, integrityInit, loosenedPolicy, writeManifest } from "./integrity";
16
16
  export type { IntegrityFinding, IntegrityOptions } from "./integrity";
17
17
  export { releaseCheck } from "./release";
18
+ export { defaultTopology, topologyFor } from "./topology";
19
+ export type { Topology } from "./topology";
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readdir, readFile } from "node:fs/promises";
3
3
  import { join, relative, resolve } from "node:path";
4
+ import { topologyFor } from "./topology";
4
5
 
5
6
  export type PlanKind = "wagon" | "feature" | "wmbt" | "acceptance" | "train" | "interlocking" | "journey" | "contract";
6
7
  export type PlanFinding = { rule_id: string; file: string; evidence: string };
@@ -16,6 +17,7 @@ async function walk(root: string): Promise<string[]> {
16
17
  const entries = await readdir(root, { withFileTypes: true });
17
18
  return (await Promise.all(entries.map(async entry => {
18
19
  const path = join(root, entry.name);
20
+ if (entry.isDirectory() && entry.name === "_generated") return [];
19
21
  return entry.isDirectory() ? walk(path) : entry.isFile() && /\.(?:yaml|yml|json)$/.test(entry.name) ? [path] : [];
20
22
  }))).flat().sort();
21
23
  }
@@ -65,7 +67,7 @@ function structuralRefs(artifact: PlanArtifact): string[] {
65
67
  /** Load plan artifacts with Bun's native YAML parser. It is read-only and deliberately
66
68
  * excludes authoring/session/store behavior from ATDD core. */
67
69
  export async function loadPlan(root = process.cwd()): Promise<PlanGraph> {
68
- const absolute = resolve(root), plan = join(absolute, "plan"), artifacts: PlanArtifact[] = [], findings: PlanFinding[] = [];
70
+ const absolute = resolve(root), topology = await topologyFor(absolute), plan = join(absolute, topology.planRoot), artifacts: PlanArtifact[] = [], findings: PlanFinding[] = [];
69
71
  for (const path of await walk(plan)) {
70
72
  const file = relative(absolute, path).replaceAll("\\", "/");
71
73
  try {
@@ -96,7 +98,7 @@ export async function validatePlan(root = process.cwd()): Promise<PlanGraph> {
96
98
  const references = structuralRefs(artifact);
97
99
  for (const reference of new Set(references)) if (!known.has(reference) && !reference.startsWith("contract:")) graph.findings.push(finding("planner.kernel.reference-resolves", artifact.file, `${artifact.id} references undeclared ${reference}`));
98
100
  if (artifact.kind === "train") for (const wagon of values(artifact.data.wagons)) { const reference = `wagon:${id(wagon)}`; if (id(wagon) && !known.has(reference)) graph.findings.push(finding("planner.kernel.train-wagon-resolves", artifact.file, `${artifact.id} lists undeclared ${reference}`)); }
99
- if (artifact.kind === "interlocking") for (const participant of [...values(artifact.data.participants), ...values(artifact.data.lifelines).map(value => value && typeof value === "object" ? (value as Record<string, unknown>).ref : value)]) { const reference = id(participant); if (reference.startsWith("wagon:") && !known.has(reference)) graph.findings.push(finding("planner.kernel.interlocking-participant-resolves", artifact.file, `${artifact.id} lists undeclared ${reference}`)); }
101
+ if (artifact.kind === "interlocking") for (const participant of [...values(artifact.data.participants), ...values(artifact.data.lifelines).map(value => value && typeof value === "object" ? (value as Record<string, unknown>).ref : value)]) { const reference = id(participant); if (reference && !/^(user|system):/.test(reference) && !known.has(reference)) graph.findings.push(finding("planner.kernel.interlocking-participant-resolves", artifact.file, `${artifact.id} lists undeclared ${reference}`)); }
100
102
  }
101
103
  return graph;
102
104
  }
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { join, resolve } from "node:path";
6
6
  import { loadPlan, type PlanArtifact, type PlanFinding } from "./planner-kernel";
7
+ import { topologyFor } from "./topology";
7
8
 
8
9
  type SchemaKind = Extract<PlanArtifact["kind"], "wagon" | "feature" | "wmbt" | "train" | "interlocking" | "journey">;
9
10
 
@@ -16,10 +17,11 @@ const schemaFiles: Record<SchemaKind, string> = {
16
17
  journey: "journey.schema.json",
17
18
  };
18
19
  const supportingSchemaFiles = ["appendix.schema.json", "acceptance.schema.json"];
19
- const repositorySchemas = [
20
- { file: "plan/_themes.yaml", schema: "theme-registry.schema.json", rule: "planner.theme.must-be-canonical" },
20
+ const repositorySchemas = (planRoot: string) => [
21
+ { file: `${planRoot}/_themes.yaml`, schema: "theme-registry.schema.json", rule: "planner.theme.must-be-canonical" },
21
22
  { file: "contracts/_contracts.yaml", schema: "contract-registry.schema.json", rule: "planner.contract.registry-coherence" },
22
23
  ] as const;
24
+ const repositorySchemaFiles = ["theme-registry.schema.json", "contract-registry.schema.json"];
23
25
  const schemaDirectory = join(resolve(import.meta.dir, ".."), "planner-schemas");
24
26
  const ruleId = "atdd-bun.planner.schema";
25
27
 
@@ -39,23 +41,23 @@ function formatError(error: ErrorObject): string {
39
41
  async function validators(): Promise<{ artifacts: Map<SchemaKind, ValidateFunction>; repositories: Map<string, ValidateFunction> }> {
40
42
  const ajv = new Ajv({ allErrors: true, allowUnionTypes: true, strict: false });
41
43
  addFormats(ajv);
42
- const files = [...supportingSchemaFiles, ...Object.values(schemaFiles), ...repositorySchemas.map(item => item.schema)];
44
+ const files = [...supportingSchemaFiles, ...Object.values(schemaFiles), ...repositorySchemaFiles];
43
45
  for (const file of files) ajv.addSchema(JSON.parse(await readFile(join(schemaDirectory, file), "utf8")), file);
44
46
  const artifacts = new Map(Object.entries(schemaFiles).map(([kind, file]) => {
45
47
  const validator = ajv.getSchema(file);
46
48
  if (!validator) throw new Error("could not compile planner schema " + file);
47
49
  return [kind as SchemaKind, validator];
48
50
  }));
49
- const repositories = new Map(repositorySchemas.map(item => {
50
- const validator = ajv.getSchema(item.schema);
51
- if (!validator) throw new Error("could not compile planner schema " + item.schema);
52
- return [item.schema, validator];
51
+ const repositories = new Map(repositorySchemaFiles.map(schema => {
52
+ const validator = ajv.getSchema(schema);
53
+ if (!validator) throw new Error("could not compile planner schema " + schema);
54
+ return [schema, validator];
53
55
  }));
54
56
  return { artifacts, repositories };
55
57
  }
56
58
 
57
59
  function repositoryRule(file: string, fallback: string, error: ErrorObject): string {
58
- if (file === "plan/_themes.yaml" && (error.instancePath === "/themes" || error.instancePath === "/themes/0" || error.params.missingProperty === "0")) {
60
+ if (file.endsWith("/_themes.yaml") && (error.instancePath === "/themes" || error.instancePath === "/themes/0" || error.params.missingProperty === "0")) {
59
61
  return "planner.theme.theme-zero-mandatory";
60
62
  }
61
63
  return fallback;
@@ -64,7 +66,7 @@ function repositoryRule(file: string, fallback: string, error: ErrorObject): str
64
66
  /** Validate every recognized plan artifact against the package-shipped canonical
65
67
  * JSON Schema. Cross-artifact relationships remain planner validator concerns. */
66
68
  export async function validatePlannerSchemas(root = process.cwd()): Promise<PlanFinding[]> {
67
- const absolute = resolve(root), graph = await loadPlan(absolute);
69
+ const absolute = resolve(root), topology = await topologyFor(absolute), graph = await loadPlan(absolute), repositories = repositorySchemas(topology.planRoot);
68
70
  const byKind = await validators();
69
71
  const findings: PlanFinding[] = [];
70
72
  for (const artifact of graph.artifacts) {
@@ -79,7 +81,7 @@ export async function validatePlannerSchemas(root = process.cwd()): Promise<Plan
79
81
  });
80
82
  }
81
83
  }
82
- for (const item of repositorySchemas) {
84
+ for (const item of repositories) {
83
85
  const path = join(absolute, item.file);
84
86
  if (!existsSync(path)) continue;
85
87
  try {
@@ -2,6 +2,7 @@ import { validatePlan, type PlanArtifact, type PlanFinding } from "./planner-ker
2
2
  import { existsSync } from "node:fs";
3
3
  import { readdir, readFile } from "node:fs/promises";
4
4
  import { join, relative, resolve } from "node:path";
5
+ import { topologyFor } from "./topology";
5
6
 
6
7
  const finding = (rule_id: string, file: string, evidence: string): PlanFinding => ({ rule_id, file, evidence });
7
8
  const records = (value: unknown): Record<string, unknown>[] => Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object") : [];
@@ -16,13 +17,13 @@ async function yamlFiles(root: string): Promise<string[]> {
16
17
  }
17
18
 
18
19
  async function trainRegistryFindings(root: string): Promise<PlanFinding[]> {
19
- const absolute = resolve(root), registryPath = join(absolute, "plan/_trains.yaml"); if (!existsSync(registryPath)) return [];
20
+ const absolute = resolve(root), topology = await topologyFor(absolute), file = `${topology.planRoot}/_trains.yaml`, registryPath = join(absolute, file); if (!existsSync(registryPath)) return [];
20
21
  const registry = Bun.YAML.parse(await readFile(registryPath, "utf8")) as { trains?: Record<string, Record<string, unknown[]>> };
21
22
  const rows = Object.values(registry?.trains ?? {}).flatMap(buckets => Object.values(buckets ?? {}).flat()).filter((row): row is Record<string, unknown> => Boolean(row) && typeof row === "object");
22
23
  const registered = new Set<string>(), findings: PlanFinding[] = [];
23
- for (const row of rows) { const path = text(row.path), train = text(row.train_id); if (!path || !train) continue; registered.add(path); if (!existsSync(join(absolute, path))) findings.push(finding("planner.train.registry-coherence", "plan/_trains.yaml", `registry train ${train} points to missing ${path}`)); }
24
- const trainRoot = join(absolute, "plan/_trains");
25
- for (const path of await yamlFiles(trainRoot)) { const rel = relative(absolute, path).replaceAll("\\", "/"), pieces = relative(trainRoot, path).split(/[\\/]/); if (pieces.some(piece => piece.startsWith("_")) || registered.has(rel)) continue; findings.push(finding("planner.train.registry-coherence", rel, `train document is not registered in plan/_trains.yaml`)); }
24
+ for (const row of rows) { const path = text(row.path), train = text(row.train_id); if (!path || !train) continue; registered.add(path); if (!existsSync(join(absolute, path))) findings.push(finding("planner.train.registry-coherence", file, `registry train ${train} points to missing ${path}`)); }
25
+ const trainRoot = join(absolute, topology.planRoot, "_trains");
26
+ for (const path of await yamlFiles(trainRoot)) { const rel = relative(absolute, path).replaceAll("\\", "/"), pieces = relative(trainRoot, path).split(/[\\/]/); if (pieces.some(piece => piece.startsWith("_")) || registered.has(rel)) continue; findings.push(finding("planner.train.registry-coherence", rel, `train document is not registered in ${file}`)); }
26
27
  return findings;
27
28
  }
28
29
 
@@ -44,7 +45,7 @@ async function contractRegistry(root: string): Promise<ContractRegistry | null>
44
45
  }
45
46
 
46
47
  async function themeRegistryFindings(root: string, graph: Awaited<ReturnType<typeof validatePlan>>, registry: ContractRegistry | null): Promise<PlanFinding[]> {
47
- const absolute = resolve(root), file = "plan/_themes.yaml", path = join(absolute, file);
48
+ const absolute = resolve(root), topology = await topologyFor(absolute), file = `${topology.planRoot}/_themes.yaml`, path = join(absolute, file);
48
49
  const declared: Array<{ theme: string; file: string }> = [];
49
50
  for (const artifact of graph.artifacts) {
50
51
  if (artifact.kind === "wagon" || artifact.kind === "interlocking") {
@@ -54,7 +55,7 @@ async function themeRegistryFindings(root: string, graph: Awaited<ReturnType<typ
54
55
  }
55
56
  }
56
57
  for (const row of registry?.rows ?? []) { const theme = text(row.theme); if (theme) declared.push({ theme, file: registry?.file ?? file }); }
57
- if (!existsSync(path)) return declared.length ? [finding("planner.theme.must-be-canonical", file, "themes are declared but plan/_themes.yaml is absent; add themes: { '0': commons } and repository-defined entries")] : [];
58
+ if (!existsSync(path)) return declared.length ? [finding("planner.theme.must-be-canonical", file, `themes are declared but ${file} is absent; add themes: { '0': commons } and repository-defined entries`)] : [];
58
59
  try {
59
60
  const doc = Bun.YAML.parse(await readFile(path, "utf8")) as { themes?: unknown };
60
61
  const themes = doc?.themes && typeof doc.themes === "object" && !Array.isArray(doc.themes) ? doc.themes as Record<string, unknown> : {};
@@ -63,7 +64,7 @@ async function themeRegistryFindings(root: string, graph: Awaited<ReturnType<typ
63
64
  if (themes["0"] !== "commons") findings.push(finding("planner.theme.theme-zero-mandatory", file, "theme index 0 must be the reserved token commons"));
64
65
  for (const name of duplicate(values)) findings.push(finding("planner.theme.must-be-canonical", file, `theme ${name} is declared at more than one index`));
65
66
  const known = new Set(values);
66
- for (const item of declared) if (!known.has(item.theme)) findings.push(finding("planner.theme.must-be-canonical", item.file, `theme ${item.theme} is not declared in plan/_themes.yaml`));
67
+ for (const item of declared) if (!known.has(item.theme)) findings.push(finding("planner.theme.must-be-canonical", item.file, `theme ${item.theme} is not declared in ${file}`));
67
68
  for (const artifact of graph.artifacts.filter(item => item.kind === "wagon")) {
68
69
  const wagonTheme = text(artifact.data.theme);
69
70
  for (const produced of records(artifact.data.produce)) {
@@ -82,7 +83,7 @@ async function themeRegistryFindings(root: string, graph: Awaited<ReturnType<typ
82
83
  }
83
84
  return findings;
84
85
  } catch {
85
- return [finding("planner.theme.must-be-canonical", file, "could not parse plan/_themes.yaml")];
86
+ return [finding("planner.theme.must-be-canonical", file, `could not parse ${file}`)];
86
87
  }
87
88
  }
88
89
 
@@ -247,7 +248,7 @@ function journeyContinuationFindings(graph: Awaited<ReturnType<typeof validatePl
247
248
  }
248
249
 
249
250
  /** Every interlocking is reached by a journey: as an entrypoint or through a continuation reachable from one. */
250
- function journeyCompositionFindings(graph: Awaited<ReturnType<typeof validatePlan>>): PlanFinding[] {
251
+ function journeyCompositionFindings(graph: Awaited<ReturnType<typeof validatePlan>>, planRoot: string): PlanFinding[] {
251
252
  const interlockings = graph.artifacts.filter(item => item.kind === "interlocking"), journeys = graph.artifacts.filter(item => item.kind === "journey");
252
253
  const reached = new Set<string>();
253
254
  for (const journey of journeys) {
@@ -270,7 +271,7 @@ function journeyCompositionFindings(graph: Awaited<ReturnType<typeof validatePla
270
271
  interlocking.file,
271
272
  journeys.length
272
273
  ? `${interlocking.id} is reached by none of the ${journeys.length} journey(s): no journey enters at it and no reachable continuation leads to it`
273
- : `${interlocking.id} is not composed into any journey: the plan declares ${interlockings.length} interlocking(s) and no journey under plan/_journeys/`,
274
+ : `${interlocking.id} is not composed into any journey: the plan declares ${interlockings.length} interlocking(s) and no journey under ${planRoot}/_journeys/`,
274
275
  ));
275
276
  }
276
277
 
@@ -280,11 +281,11 @@ export async function validateStaticPlannerConventions(root = process.cwd()): Pr
280
281
  // Plan-graph integrity is a package guard with its own implementation contract.
281
282
  // This validator emits only the canonical convention ids declared by
282
283
  // planner.static.validators.bun.
283
- const graph = await validatePlan(root), findings: PlanFinding[] = [], wagons = graph.artifacts.filter(artifact => artifact.kind === "wagon");
284
+ const topology = await topologyFor(root), graph = await validatePlan(root), findings: PlanFinding[] = [], wagons = graph.artifacts.filter(artifact => artifact.kind === "wagon");
284
285
  const wagonSlugs = new Set(wagons.map(wagon => text(wagon.data.wagon)));
285
286
  const wagonBySlug = new Map(wagons.map(wagon => [text(wagon.data.wagon), wagon]));
286
287
  const wagonIds = wagons.map(wagon => text(wagon.data.wagon));
287
- for (const slug of duplicate(wagonIds)) findings.push(finding("planner.wagon.urn", wagonBySlug.get(slug)?.file ?? "plan/", `wagon slug ${slug} is declared more than once`));
288
+ for (const slug of duplicate(wagonIds)) findings.push(finding("planner.wagon.urn", wagonBySlug.get(slug)?.file ?? `${topology.planRoot}/`, `wagon slug ${slug} is declared more than once`));
288
289
 
289
290
  const contractOwners = new Map<string, string[]>(), telemetryOwners = new Map<string, string[]>();
290
291
  for (const wagon of wagons) {
@@ -305,13 +306,13 @@ export async function validateStaticPlannerConventions(root = process.cwd()): Pr
305
306
  }
306
307
  }
307
308
  const registry = await contractRegistry(root);
308
- for (const [contract, owners] of contractOwners) if (owners.length > 1) findings.push(finding("planner.contract.registry-coherence", wagonBySlug.get(owners[0])?.file ?? "plan/", `contract ${contract} is produced by ${owners.join(", ")}`));
309
- for (const [telemetry, owners] of telemetryOwners) if (owners.length > 1) findings.push(finding("planner.wagon.telemetry-filesystem", wagonBySlug.get(owners[0])?.file ?? "plan/", `telemetry ${telemetry} is produced by ${owners.join(", ")}`));
309
+ for (const [contract, owners] of contractOwners) if (owners.length > 1) findings.push(finding("planner.contract.registry-coherence", wagonBySlug.get(owners[0])?.file ?? `${topology.planRoot}/`, `contract ${contract} is produced by ${owners.join(", ")}`));
310
+ for (const [telemetry, owners] of telemetryOwners) if (owners.length > 1) findings.push(finding("planner.wagon.telemetry-filesystem", wagonBySlug.get(owners[0])?.file ?? `${topology.planRoot}/`, `telemetry ${telemetry} is produced by ${owners.join(", ")}`));
310
311
  findings.push(...contractRegistryFindings(root, wagons, registry));
311
312
  findings.push(...await themeRegistryFindings(root, graph, registry));
312
313
  findings.push(...await trainRegistryFindings(root));
313
314
  findings.push(...journeyContinuationFindings(graph));
314
- findings.push(...journeyCompositionFindings(graph));
315
+ findings.push(...journeyCompositionFindings(graph, topology.planRoot));
315
316
 
316
317
  return findings;
317
318
  }
@@ -0,0 +1,40 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+
5
+ export type Topology = { planRoot: string; sourceRoot: string; testRoot: string; e2eRoot: string };
6
+
7
+ export const defaultTopology: Topology = {
8
+ planRoot: "plan",
9
+ sourceRoot: "src/wagons",
10
+ testRoot: "tests/wagons",
11
+ e2eRoot: "e2e",
12
+ };
13
+
14
+ const configuredKeys: Array<[keyof Topology, string]> = [
15
+ ["planRoot", "plan_root"],
16
+ ["sourceRoot", "source_root"],
17
+ ["testRoot", "test_root"],
18
+ ["e2eRoot", "e2e_root"],
19
+ ];
20
+
21
+ function safeRelative(value: unknown): string | null {
22
+ if (typeof value !== "string" || !value || value.startsWith("/")) return null;
23
+ const normalized = value.replaceAll("\\", "/").replace(/\/$/, "");
24
+ return normalized && !normalized.split("/").includes("..") ? normalized : null;
25
+ }
26
+
27
+ /** Read the one shared topology configuration used by every plan consumer. Invalid
28
+ * location overrides deliberately fall back to the strict defaults; the topology
29
+ * detector reports the bad configuration as a violation. */
30
+ export async function topologyFor(root = process.cwd()): Promise<Topology> {
31
+ const absolute = resolve(root), file = join(absolute, "atdd-bun.yaml");
32
+ if (!existsSync(file)) return { ...defaultTopology };
33
+ try {
34
+ const data = Bun.YAML.parse(await readFile(file, "utf8")) as { topology?: Record<string, unknown> } | null;
35
+ const supplied = data?.topology && typeof data.topology === "object" && !Array.isArray(data.topology) ? data.topology : {};
36
+ const topology = { ...defaultTopology };
37
+ for (const [property, key] of configuredKeys) topology[property] = safeRelative(supplied[key]) ?? topology[property];
38
+ return topology;
39
+ } catch { return { ...defaultTopology }; }
40
+ }