@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.
- package/README.md +30 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml +27 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml +28 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.identity-unique.convention.yaml +27 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.interlocking-participant-resolves.convention.yaml +28 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.parse.convention.yaml +27 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.reference-resolves.convention.yaml +28 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.schema.convention.yaml +30 -0
- package/conventions/atdd-bun.planner/atdd-bun.planner.train-wagon-resolves.convention.yaml +26 -0
- package/conventions/atdd-bun.topology/atdd-bun.topology.e2e-location.convention.yaml +38 -0
- package/conventions/atdd-bun.topology/atdd-bun.topology.feature-source-coverage.convention.yaml +30 -0
- package/conventions/atdd-bun.topology/atdd-bun.topology.feature-test-coverage.convention.yaml +29 -0
- package/conventions/atdd-bun.topology/atdd-bun.topology.plan-location.convention.yaml +31 -0
- package/conventions/atdd-bun.topology/atdd-bun.topology.source-location.convention.yaml +33 -0
- package/conventions/atdd-bun.topology/atdd-bun.topology.test-location.convention.yaml +34 -0
- package/detectors/atdd_topology/atdd.implementation.yaml +16 -0
- package/detectors/atdd_topology/detect.mjs +182 -0
- package/detectors/atdd_topology/fixtures/clean/atdd-bun.yaml +2 -0
- package/detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts +4 -0
- package/detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts +5 -0
- package/detectors/atdd_topology/fixtures/clean/implementation/orders/features/place-order/domain/order.ts +4 -0
- package/detectors/atdd_topology/fixtures/clean/plan/_journeys/checkout.yaml +4 -0
- package/detectors/atdd_topology/fixtures/clean/plan/_trains/_interlockings/checkout.yaml +4 -0
- package/detectors/atdd_topology/fixtures/clean/plan/_trains/checkout.yaml +1 -0
- package/detectors/atdd_topology/fixtures/clean/plan/orders/E001.yaml +8 -0
- package/detectors/atdd_topology/fixtures/clean/plan/orders/_orders.yaml +5 -0
- package/detectors/atdd_topology/fixtures/clean/plan/orders/place-order.yaml +3 -0
- package/detectors/atdd_topology/fixtures/clean/tests/wagons/orders/features/place-order/unit/order.test.ts +4 -0
- package/detectors/atdd_topology/fixtures/dirty/plan/_journeys/checkout.yaml +3 -0
- package/detectors/atdd_topology/fixtures/dirty/plan/orders/E001.yaml +4 -0
- package/detectors/atdd_topology/fixtures/dirty/plan/orders/_orders.yaml +4 -0
- package/detectors/atdd_topology/fixtures/dirty/plan/orders/features/place-order.yaml +3 -0
- package/detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/forged.ts +2 -0
- package/detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/order.ts +2 -0
- package/detectors/atdd_topology/fixtures/dirty/tests/wagons/orders/features/place-order/unit/order.test.ts +4 -0
- package/detectors/atdd_traceability_closure/detect.mjs +5 -2
- package/detectors/bun_interlocking_binding/checks/interlocking_runtime_executes.mjs +3 -2
- package/detectors/bun_interlocking_binding/detect.mjs +6 -5
- package/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +4 -3
- package/detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs +2 -2
- package/detectors/bun_interlocking_infrastructure/_shared/interlocking.mjs +2 -1
- package/detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs +3 -2
- package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +2 -1
- package/detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs +3 -2
- package/detectors/bun_tester_discipline_detector/checks/t_acceptance_resolves.mjs +7 -6
- package/detectors/planner_docs_capability/atdd.implementation.yaml +7 -0
- package/detectors/planner_plan_integrity/atdd.implementation.yaml +1 -0
- package/detectors/planner_plan_integrity/detect.mjs +4 -1
- package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/_interlockings/checkout.yaml +6 -0
- package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/_interlockings/unnamed.yaml +2 -0
- package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/dup-a.yaml +7 -0
- package/detectors/planner_plan_integrity/fixtures/dirty/plan/_trains/dup-b.yaml +6 -0
- package/detectors/planner_plan_integrity/fixtures/dirty/plan/orders/E001.yaml +5 -0
- package/detectors/planner_schema_validation/fixtures/dirty/plan/_trains/missing-description.yaml +5 -0
- package/integrity.json +65 -24
- package/package.json +1 -1
- package/planner-nodes/ENFORCEMENT_SCOPE.yaml +11 -1
- package/relationships.yaml +224 -0
- package/src/docs-capability.ts +3 -0
- package/src/enforce.ts +30 -5
- package/src/hooks.ts +2 -1
- package/src/index.ts +2 -0
- package/src/planner-kernel.ts +4 -2
- package/src/planner-schema-validator.ts +12 -10
- package/src/planner-validators.ts +16 -15
- 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("
|
|
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";
|
package/src/planner-kernel.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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:
|
|
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), ...
|
|
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(
|
|
50
|
-
const validator = ajv.getSchema(
|
|
51
|
-
if (!validator) throw new Error("could not compile planner schema " +
|
|
52
|
-
return [
|
|
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
|
|
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
|
|
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),
|
|
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",
|
|
24
|
-
const trainRoot = join(absolute, "
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 ??
|
|
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 ??
|
|
309
|
-
for (const [telemetry, owners] of telemetryOwners) if (owners.length > 1) findings.push(finding("planner.wagon.telemetry-filesystem", wagonBySlug.get(owners[0])?.file ??
|
|
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
|
}
|
package/src/topology.ts
ADDED
|
@@ -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
|
+
}
|