@afokapu/atdd-bun 0.5.0 → 0.6.0
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/detectors/atdd_topology/atdd.implementation.yaml +13 -0
- package/detectors/atdd_topology/detect.mjs +171 -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 +4 -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 +6 -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/integrity.json +39 -18
- package/package.json +1 -1
- package/src/enforce.ts +8 -5
- package/src/hooks.ts +2 -1
- package/src/index.ts +2 -0
- package/src/planner-kernel.ts +2 -1
- package/src/planner-schema-validator.ts +12 -10
- package/src/planner-validators.ts +16 -15
- package/src/topology.ts +40 -0
package/README.md
CHANGED
|
@@ -97,6 +97,7 @@ name.
|
|
|
97
97
|
| Profile | Use it when you need to check |
|
|
98
98
|
|---|---|
|
|
99
99
|
| `traceability` | plan acceptance, Bun test, and implementation closure |
|
|
100
|
+
| `topology` | feature decomposition and canonical plan, source, test, and E2E locations |
|
|
100
101
|
| `planner` | plan parsing/graph integrity plus the explicitly scoped planner rules |
|
|
101
102
|
| `docs` | the optional documentation capability and its declared artifacts, including the generated journey view |
|
|
102
103
|
| `coder`, `tester`, `security`, `architecture`, `metrics`, `runtime` | Bun source and test conventions for that concern |
|
|
@@ -123,6 +124,35 @@ carry Station Master actions; the Bun interlocking family checks those actions r
|
|
|
123
124
|
Hooks, direct CLI use, and CI invoke this same profile and therefore share the
|
|
124
125
|
same schema source.
|
|
125
126
|
|
|
127
|
+
### Repository topology
|
|
128
|
+
|
|
129
|
+
The topology gate makes a feature a required plan artifact instead of an
|
|
130
|
+
optional label on a WMBT. Its default layout is:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
plan/<wagon>/{_<wagon>,<feature>,<WMBT>}.yaml
|
|
134
|
+
src/wagons/<wagon>/features/<feature>/{domain,application,infrastructure,presentation}/
|
|
135
|
+
tests/wagons/<wagon>/features/<feature>/{unit,contract,integration}/
|
|
136
|
+
e2e/interlockings/<interlocking>/<route>.routes.test.ts
|
|
137
|
+
e2e/journeys/<journey>.journey.test.ts
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Each feature must be declared by exactly one wagon and each WMBT by exactly one
|
|
141
|
+
feature. Source and test headers must name the feature in their path; tests bind
|
|
142
|
+
with `Acceptance:`, while exposed journey E2E tests bind with `Train:`. The
|
|
143
|
+
component-URN `integration` layer maps to the `infrastructure/` directory.
|
|
144
|
+
|
|
145
|
+
Change the four roots for an existing repository in `atdd-bun.yaml`; the same
|
|
146
|
+
validation then follows the configured locations:
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
topology:
|
|
150
|
+
plan_root: plan
|
|
151
|
+
source_root: src/wagons
|
|
152
|
+
test_root: tests/wagons
|
|
153
|
+
e2e_root: e2e
|
|
154
|
+
```
|
|
155
|
+
|
|
126
156
|
### Design system
|
|
127
157
|
|
|
128
158
|
The `design` profile (also part of `coder`) is the Bun realization of the core
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
kind: implementation
|
|
2
|
+
subtype: guard
|
|
3
|
+
implementation_id: atdd_topology
|
|
4
|
+
emits_rule_ids:
|
|
5
|
+
- planner.wagon.features
|
|
6
|
+
- planner.feature.wagon-link
|
|
7
|
+
- atdd-bun.topology.plan-location
|
|
8
|
+
- atdd-bun.topology.source-location
|
|
9
|
+
- atdd-bun.topology.test-location
|
|
10
|
+
- atdd-bun.topology.e2e-location
|
|
11
|
+
- atdd-bun.topology.feature-source-coverage
|
|
12
|
+
- atdd-bun.topology.feature-test-coverage
|
|
13
|
+
entrypoint: detect.mjs
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Configurable plan/source/test/E2E topology and feature-decomposition gate.
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
|
+
import { topologyFor } from "../../src/topology.ts";
|
|
6
|
+
|
|
7
|
+
const roots = JSON.parse(process.env.ATDD_SCAN_ROOTS || "[]"), report = process.env.ATDD_VIOLATIONS_REPORT;
|
|
8
|
+
if (!report) process.exit(2);
|
|
9
|
+
const sourceExt = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".mts", ".cts", ".cjs", ".html", ".htm"]);
|
|
10
|
+
const testName = /(?:^test_.*|.*(?:\.test|\.spec|_test))\.(?:[cm]?[jt]sx?)$/;
|
|
11
|
+
const slug = "[a-z][a-z0-9-]*";
|
|
12
|
+
const componentUrn = new RegExp(`^component:(${slug}):(${slug}):[A-Za-z0-9.]+:(frontend|backend):(domain|application|integration|presentation)$`);
|
|
13
|
+
const testUrn = new RegExp(`^test:(${slug}):(${slug}):([A-Za-z0-9][A-Za-z0-9._-]*)$`);
|
|
14
|
+
const violations = [];
|
|
15
|
+
const rel = (root, path) => relative(root, path).split(sep).join("/");
|
|
16
|
+
const read = path => { try { return readFileSync(path, "utf8"); } catch { return ""; } };
|
|
17
|
+
const text = (data, key) => data && typeof data === "object" && !Array.isArray(data) && typeof data[key] === "string" ? data[key] : "";
|
|
18
|
+
const list = (data, key) => data && typeof data === "object" && Array.isArray(data[key]) ? data[key] : [];
|
|
19
|
+
const urns = values => values.map(value => typeof value === "string" ? value : text(value, "urn")).filter(Boolean);
|
|
20
|
+
function add(rule_id, root, path, evidence, line = 1, source_line = "") { violations.push({ rule_id, file: rel(root, path), line, col: 1, evidence, source_line }); }
|
|
21
|
+
function walk(dir, predicate, found = []) {
|
|
22
|
+
let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return found; }
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
if (["node_modules", ".git", ".atdd", "dist", "build", ".next"].includes(entry.name)) continue;
|
|
25
|
+
const path = join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory()) walk(path, predicate, found); else if (predicate(path)) found.push(path);
|
|
27
|
+
}
|
|
28
|
+
return found;
|
|
29
|
+
}
|
|
30
|
+
async function topology(root) { const value = await topologyFor(root); return { plan_root: value.planRoot, source_root: value.sourceRoot, test_root: value.testRoot, e2e_root: value.e2eRoot }; }
|
|
31
|
+
function header(path) {
|
|
32
|
+
const lines = read(path).split(/\r?\n/).slice(0, 60);
|
|
33
|
+
const field = name => {
|
|
34
|
+
const index = lines.findIndex(line => new RegExp(`^\\s*//\\s*${name}:\\s*(\\S.*?)\\s*$`).test(line));
|
|
35
|
+
return index < 0 ? { value: "", line: 1, raw: "" } : { value: lines[index].replace(new RegExp(`^\\s*//\\s*${name}:\\s*`), "").trim(), line: index + 1, raw: lines[index].trim() };
|
|
36
|
+
};
|
|
37
|
+
return { urn: field("URN"), acceptance: field("Acceptance"), train: field("Train"), journey: field("Journey") };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
const cfg = await topology(root), planRoot = join(root, cfg.plan_root), docs = [];
|
|
42
|
+
for (const path of walk(planRoot, path => /\.ya?ml$/.test(path))) {
|
|
43
|
+
try { const data = Bun.YAML.parse(read(path)); if (data && typeof data === "object" && !Array.isArray(data)) docs.push({ path, data }); } catch { /* schema validation owns parse errors */ }
|
|
44
|
+
}
|
|
45
|
+
const wagons = docs.filter(doc => text(doc.data, "urn").startsWith("wagon:")).map(doc => ({ ...doc, slug: text(doc.data, "urn").slice(6) }));
|
|
46
|
+
const features = docs.filter(doc => text(doc.data, "urn").startsWith("feature:")).map(doc => { const [,, wagon, slug] = ["", ...text(doc.data, "urn").split(":")]; return { ...doc, wagon, slug, urn: text(doc.data, "urn") }; });
|
|
47
|
+
const wmbts = docs.filter(doc => text(doc.data, "urn").startsWith("wmbt:")).map(doc => { const [, wagon, code] = text(doc.data, "urn").split(":"); return { ...doc, wagon, code, urn: text(doc.data, "urn") }; });
|
|
48
|
+
const featureByUrn = new Map(features.map(feature => [feature.urn, feature])), memberships = new Map(), wmbtOwners = new Map(), sourceByFeature = new Map(), testByFeature = new Map();
|
|
49
|
+
const acceptances = new Set(wmbts.flatMap(wmbt => list(wmbt.data, "acceptances").map(acceptance => text(acceptance?.identity, "urn")).filter(Boolean)));
|
|
50
|
+
const trains = new Set(docs.map(doc => text(doc.data, "train_id")).filter(Boolean));
|
|
51
|
+
|
|
52
|
+
for (const wagon of wagons) {
|
|
53
|
+
const expected = `${cfg.plan_root}/${wagon.slug}/_${wagon.slug}.yaml`;
|
|
54
|
+
if (rel(root, wagon.path) !== expected) add("atdd-bun.topology.plan-location", root, wagon.path, `wagon:${wagon.slug} must be declared at ${expected}`);
|
|
55
|
+
const declared = urns(list(wagon.data, "features"));
|
|
56
|
+
if ((Number(wagon.data.wmbt?.total) > 0 || wmbts.some(wmbt => wmbt.wagon === wagon.slug)) && !declared.length) add("planner.wagon.features", root, wagon.path, `wagon:${wagon.slug} has WMBTs but no feature decomposition`);
|
|
57
|
+
for (const urn of declared) {
|
|
58
|
+
const feature = featureByUrn.get(urn);
|
|
59
|
+
if (!feature) { add("planner.wagon.features", root, wagon.path, `wagon:${wagon.slug} lists ${urn}, but no feature document declares it`); continue; }
|
|
60
|
+
memberships.set(urn, [...(memberships.get(urn) || []), wagon.slug]);
|
|
61
|
+
if (feature.wagon !== wagon.slug || text(feature.data, "wagon") !== `wagon:${wagon.slug}`) add("planner.feature.wagon-link", root, feature.path, `${urn} must belong to wagon:${wagon.slug}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const feature of features) {
|
|
65
|
+
const expected = `${cfg.plan_root}/${feature.wagon}/${feature.slug}.yaml`, owners = memberships.get(feature.urn) || [];
|
|
66
|
+
if (rel(root, feature.path) !== expected) add("atdd-bun.topology.plan-location", root, feature.path, `${feature.urn} must be declared at ${expected}`);
|
|
67
|
+
if (owners.length !== 1) add("planner.feature.wagon-link", root, feature.path, `${feature.urn} must be listed by exactly one wagon; found ${owners.length}`);
|
|
68
|
+
for (const urn of urns(list(feature.data, "wmbts"))) wmbtOwners.set(urn, [...(wmbtOwners.get(urn) || []), feature.urn]);
|
|
69
|
+
}
|
|
70
|
+
for (const wmbt of wmbts) {
|
|
71
|
+
const expected = `${cfg.plan_root}/${wmbt.wagon}/${wmbt.code}.yaml`, owners = wmbtOwners.get(wmbt.urn) || [];
|
|
72
|
+
if (rel(root, wmbt.path) !== expected) add("atdd-bun.topology.plan-location", root, wmbt.path, `${wmbt.urn} must be declared at ${expected}`);
|
|
73
|
+
if (owners.length !== 1) add("planner.wagon.features", root, wmbt.path, `${wmbt.urn} must belong to exactly one feature; found ${owners.length}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const declaredFeatures = new Set(features.map(feature => `${feature.wagon}:${feature.slug}`));
|
|
77
|
+
// A repository can ship an independent frontend or infrastructure fixture with journeys but
|
|
78
|
+
// no wagon/feature plan. Feature layout begins only when the plan models a feature; this
|
|
79
|
+
// preserves that boundary while making every modeled feature strict.
|
|
80
|
+
const modelsFeatures = wagons.length > 0 || features.length > 0 || wmbts.length > 0;
|
|
81
|
+
if (modelsFeatures) for (const path of walk(join(root, cfg.source_root), path => sourceExt.has(path.slice(path.lastIndexOf("."))) && !testName.test(path))) {
|
|
82
|
+
const h = header(path), match = componentUrn.exec(h.urn.value);
|
|
83
|
+
if (!match) { add("atdd-bun.topology.source-location", root, path, "source requires URN: component:{wagon}:{feature}:{name}:{frontend|backend}:{domain|application|integration|presentation}", h.urn.line, h.urn.raw); continue; }
|
|
84
|
+
const wagon = match[1], feature = match[2], layer = match[4], expected = `${cfg.source_root}/${wagon}/features/${feature}/${layer === "integration" ? "infrastructure" : layer}/`;
|
|
85
|
+
if (!rel(root, path).startsWith(expected)) add("atdd-bun.topology.source-location", root, path, `${h.urn.value} must live beneath ${expected}`, h.urn.line, h.urn.raw);
|
|
86
|
+
else sourceByFeature.set(`${wagon}:${feature}`, [...(sourceByFeature.get(`${wagon}:${feature}`) || []), path]);
|
|
87
|
+
if (wagon && feature && !declaredFeatures.has(`${wagon}:${feature}`)) add("planner.feature.wagon-link", root, path, `${h.urn.value} names undeclared feature:${wagon}:${feature}`, h.urn.line, h.urn.raw);
|
|
88
|
+
}
|
|
89
|
+
if (modelsFeatures) for (const path of walk(join(root, cfg.test_root), path => testName.test(path))) {
|
|
90
|
+
const h = header(path), match = testUrn.exec(h.urn.value);
|
|
91
|
+
if (!match) { add("atdd-bun.topology.test-location", root, path, "test requires URN: test:{wagon}:{feature}:{acceptance}", h.urn.line, h.urn.raw); continue; }
|
|
92
|
+
const [, wagon, feature, acceptance] = match, expected = `${cfg.test_root}/${wagon}/features/${feature}/`;
|
|
93
|
+
if (!rel(root, path).startsWith(expected) || !/(unit|contract|integration)\//.test(rel(root, path).slice(expected.length))) add("atdd-bun.topology.test-location", root, path, `${h.urn.value} must live beneath ${expected}{unit,contract,integration}/`, h.urn.line, h.urn.raw);
|
|
94
|
+
if (wagon && feature && !declaredFeatures.has(`${wagon}:${feature}`)) add("planner.feature.wagon-link", root, path, `${h.urn.value} names undeclared feature:${wagon}:${feature}`, h.urn.line, h.urn.raw);
|
|
95
|
+
const binding = `acc:${wagon}:${acceptance}`;
|
|
96
|
+
if (h.acceptance.value !== binding) add("atdd-bun.topology.test-location", root, path, `${h.urn.value} must bind Acceptance: ${binding}`, h.acceptance.line, h.acceptance.raw);
|
|
97
|
+
else if (!acceptances.has(binding)) add("atdd-bun.topology.test-location", root, path, `${binding} is not declared by a WMBT in ${cfg.plan_root}/`, h.acceptance.line, h.acceptance.raw);
|
|
98
|
+
else testByFeature.set(`${wagon}:${feature}`, [...(testByFeature.get(`${wagon}:${feature}`) || []), path]);
|
|
99
|
+
}
|
|
100
|
+
for (const feature of features) if (list(feature.data, "wmbts").length) {
|
|
101
|
+
const key = `${feature.wagon}:${feature.slug}`;
|
|
102
|
+
if (!sourceByFeature.get(key)?.length) add("atdd-bun.topology.feature-source-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no source component beneath ${cfg.source_root}/${feature.wagon}/features/${feature.slug}/`);
|
|
103
|
+
if (!testByFeature.get(key)?.length) add("atdd-bun.topology.feature-test-coverage", root, feature.path, `${feature.urn} owns WMBTs but has no test bound to one of its acceptances`);
|
|
104
|
+
}
|
|
105
|
+
const e2eRoot = join(root, cfg.e2e_root);
|
|
106
|
+
// A browser test is the E2E proof for a frontend behavior. It deliberately
|
|
107
|
+
// substitutes for the Bun E2E path below, never supplements it: one behavior,
|
|
108
|
+
// one runner. htmx_e2e_detector (included in the tester profile) owns the
|
|
109
|
+
// stricter Playwright naming, header, subject, and harness validation.
|
|
110
|
+
const browserTrains = new Set(), browserJourneys = new Set();
|
|
111
|
+
for (const path of walk(e2eRoot, path => /\.e2e\.[cm]?[jt]sx?$/.test(path))) {
|
|
112
|
+
const content = read(path), h = header(path);
|
|
113
|
+
if (!/from\s+["']@playwright\/test["']/.test(content) || !/(^|[^.\w])test(\.(describe|only|skip|fixme|fail|slow|step))?\s*\(/m.test(content)) continue;
|
|
114
|
+
if (h.train.value) browserTrains.add(h.train.value);
|
|
115
|
+
if (h.journey.value) browserJourneys.add(h.journey.value);
|
|
116
|
+
}
|
|
117
|
+
const interlockingRoutes = new Set();
|
|
118
|
+
const interlockings = new Map(docs.filter(doc => text(doc.data, "interlocking_id").startsWith("interlocking:")).map(doc => [text(doc.data, "interlocking_id"), doc]));
|
|
119
|
+
const journeys = docs.filter(doc => text(doc.data, "journey_id").startsWith("journey:"));
|
|
120
|
+
const journeyReachability = journey => {
|
|
121
|
+
const continuations = list(journey.data, "continuations"), reachable = new Set(), queue = [text(journey.data.entrypoint, "interlocking_id")].filter(Boolean);
|
|
122
|
+
while (queue.length) {
|
|
123
|
+
const interlockingId = queue.shift(); if (!interlockingId || reachable.has(interlockingId)) continue;
|
|
124
|
+
reachable.add(interlockingId);
|
|
125
|
+
for (const continuation of continuations) if (text(continuation?.from, "interlocking_id") === interlockingId && text(continuation?.to, "interlocking_id")) queue.push(text(continuation.to, "interlocking_id"));
|
|
126
|
+
}
|
|
127
|
+
return reachable;
|
|
128
|
+
};
|
|
129
|
+
const hasFrontendSurface = entry => !Array.isArray(entry?.surfaces) || entry.surfaces.map(String).includes("frontend");
|
|
130
|
+
const frontendInterlockings = new Set(journeys.filter(journey => {
|
|
131
|
+
const entry = journey.data.entrypoint;
|
|
132
|
+
return entry && typeof entry === "object" && entry.exposed === true && hasFrontendSurface(entry);
|
|
133
|
+
}).flatMap(journey => [...journeyReachability(journey)]));
|
|
134
|
+
for (const interlocking of interlockings.values()) {
|
|
135
|
+
const id = text(interlocking.data, "interlocking_id").slice(13);
|
|
136
|
+
for (const route of list(interlocking.data, "routes")) {
|
|
137
|
+
const routeId = text(route, "route_id"); if (!routeId) continue;
|
|
138
|
+
const expected = join(e2eRoot, "interlockings", id, `${routeId}.routes.test.ts`);
|
|
139
|
+
interlockingRoutes.add(rel(root, expected));
|
|
140
|
+
const browserEligible = frontendInterlockings.has(text(interlocking.data, "interlocking_id"));
|
|
141
|
+
if (!existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
|
|
142
|
+
? `${text(interlocking.data, "interlocking_id")} route ${routeId} requires ${rel(root, expected)} or a Playwright E2E spec bound to Train: ${text(route, "train_id")}`
|
|
143
|
+
: `${text(interlocking.data, "interlocking_id")} route ${routeId} is not on an exposed frontend journey and requires ${rel(root, expected)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const journey of journeys) {
|
|
147
|
+
const entry = journey.data.entrypoint, id = text(journey.data, "journey_id").slice(8), path = join(e2eRoot, "journeys", `${id}.journey.test.ts`);
|
|
148
|
+
const reachable = journeyReachability(journey), journeyTrains = new Set([...reachable].flatMap(interlockingId => list(interlockings.get(interlockingId)?.data, "routes").map(route => text(route, "train_id")).filter(Boolean)));
|
|
149
|
+
const browserEligible = entry && typeof entry === "object" && hasFrontendSurface(entry);
|
|
150
|
+
if (entry && typeof entry === "object" && entry.exposed === true && !existsSync(path) && !(browserEligible && browserJourneys.has(`journey:${id}`))) add("atdd-bun.topology.e2e-location", root, journey.path, browserEligible
|
|
151
|
+
? `exposed journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts or a Playwright E2E spec bound to Journey: journey:${id}`
|
|
152
|
+
: `exposed backend journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts`);
|
|
153
|
+
else if (entry && typeof entry === "object" && entry.exposed === true && existsSync(path)) {
|
|
154
|
+
const h = header(path), match = testUrn.exec(h.urn.value);
|
|
155
|
+
if (!match || !declaredFeatures.has(`${match[1]}:${match[2]}`)) add("atdd-bun.topology.e2e-location", root, path, "journey E2E test requires a test:{wagon}:{feature}:{acceptance} URN naming a declared feature", h.urn.line, h.urn.raw);
|
|
156
|
+
if (!trains.has(h.train.value) || !journeyTrains.has(h.train.value)) add("atdd-bun.topology.e2e-location", root, path, `journey E2E Train: must resolve to a reachable selected train; found ${h.train.value || "<missing>"}`, h.train.line, h.train.raw);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const path of walk(e2eRoot, path => testName.test(path))) {
|
|
160
|
+
const location = rel(root, path), h = header(path);
|
|
161
|
+
if (location.startsWith(`${cfg.e2e_root}/journeys/`) && !/\.journey\.test\.[cm]?[jt]sx?$/.test(path)) add("atdd-bun.topology.e2e-location", root, path, "journey E2E tests must end in .journey.test.ts", h.urn.line, h.urn.raw);
|
|
162
|
+
if (location.startsWith(`${cfg.e2e_root}/interlockings/`) && !/\.routes\.test\.[cm]?[jt]sx?$/.test(path)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E tests must end in .routes.test.ts", h.urn.line, h.urn.raw);
|
|
163
|
+
if (location.startsWith(`${cfg.e2e_root}/interlockings/`) && /\.routes\.test\.[cm]?[jt]sx?$/.test(path) && interlockingRoutes.size && !interlockingRoutes.has(location)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E test does not name a declared route", h.urn.line, h.urn.raw);
|
|
164
|
+
if (location.startsWith(`${cfg.e2e_root}/interlockings/`) && /\.routes\.test\.[cm]?[jt]sx?$/.test(path)) {
|
|
165
|
+
const match = testUrn.exec(h.urn.value);
|
|
166
|
+
if (!match || !declaredFeatures.has(`${match[1]}:${match[2]}`)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E test requires a test:{wagon}:{feature}:{acceptance} URN naming a declared feature", h.urn.line, h.urn.raw);
|
|
167
|
+
else if (h.acceptance.value !== `acc:${match[1]}:${match[3]}` || !acceptances.has(h.acceptance.value)) add("atdd-bun.topology.e2e-location", root, path, "interlocking E2E test requires an Acceptance: binding declared by a WMBT", h.acceptance.line, h.acceptance.raw);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
writeFileSync(report, JSON.stringify({ violations }, null, 2));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
train_id: train:orders:checkout
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Bun-native closure gate for plan -> test -> implementation traceability.
|
|
3
3
|
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join, relative } from "node:path";
|
|
5
|
+
import { topologyFor } from "../../src/topology.ts";
|
|
5
6
|
|
|
6
7
|
const roots = JSON.parse(process.env.ATDD_SCAN_ROOTS || "[]");
|
|
7
8
|
const excludes = new Set(JSON.parse(process.env.ATDD_SCAN_EXCLUDES || "[]"));
|
|
@@ -14,6 +15,7 @@ const violations = [];
|
|
|
14
15
|
const plans = { acc: new Set(), wmbt: new Set(), train: new Set() };
|
|
15
16
|
const tests = new Map();
|
|
16
17
|
const sources = [];
|
|
18
|
+
let planDisplay = "plan/";
|
|
17
19
|
|
|
18
20
|
function ignored(path) {
|
|
19
21
|
return [...excludes].some((needle) => path.includes(needle));
|
|
@@ -33,10 +35,11 @@ function add(rule_id, file, line, evidence, source_line = "") {
|
|
|
33
35
|
function lineOf(content, token) { return content.slice(0, content.indexOf(token)).split("\n").length; }
|
|
34
36
|
|
|
35
37
|
for (const root of roots) {
|
|
38
|
+
const topology = await topologyFor(root), planPrefix = topology.planRoot.replace(/\/$/, "") + "/"; planDisplay = planPrefix;
|
|
36
39
|
walk(root, (path) => {
|
|
37
40
|
const name = path.split("/").pop() || "";
|
|
38
41
|
const content = text(path);
|
|
39
|
-
if (/\.ya?ml$/i.test(name) &&
|
|
42
|
+
if (/\.ya?ml$/i.test(name) && relative(root, path).replaceAll("\\", "/").startsWith(planPrefix)) {
|
|
40
43
|
for (const [kind, re] of Object.entries({
|
|
41
44
|
acc: /\bacc:[A-Za-z0-9_.:-]+/g,
|
|
42
45
|
wmbt: /\bwmbt:[A-Za-z0-9_.:-]+/g,
|
|
@@ -70,7 +73,7 @@ for (const test of tests.values()) {
|
|
|
70
73
|
}
|
|
71
74
|
const boundAcceptances = new Set([...tests.values()].map((test) => test.binding).filter((id) => id?.startsWith("acc:")));
|
|
72
75
|
for (const acceptance of plans.acc) {
|
|
73
|
-
if (!boundAcceptances.has(acceptance)) add("traceability.plan.executable-acceptance-has-test",
|
|
76
|
+
if (!boundAcceptances.has(acceptance)) add("traceability.plan.executable-acceptance-has-test", planDisplay, 1, `${acceptance} has no Bun test binding`, acceptance);
|
|
74
77
|
}
|
|
75
78
|
for (const source of sources) {
|
|
76
79
|
if (!source.testedBy.length) {
|
|
@@ -30,6 +30,7 @@ const LOADS_DECLARATION =
|
|
|
30
30
|
/\breadFileSync\s*\(|\breadFile\s*\(|\bBun\s*\.\s*file\s*\(|\bcreateReadStream\s*\(|\bfs\s*\.\s*promises\s*\.\s*readFile\b/;
|
|
31
31
|
const RESOLVES = /\bInterlockingResolution\b|\bresolveTrain\s*\(/;
|
|
32
32
|
const EXCLUDES = ["node_modules", "dist", "build", ".next", "_generated"];
|
|
33
|
+
const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
|
|
33
34
|
|
|
34
35
|
const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
|
|
35
36
|
|
|
@@ -53,7 +54,7 @@ export function findConsumerRoots(scanRoot) {
|
|
|
53
54
|
(function rec(d) {
|
|
54
55
|
let st; try { st = statSync(d); } catch { return; }
|
|
55
56
|
if (!st.isDirectory()) return;
|
|
56
|
-
for (const marker of ["src",
|
|
57
|
+
for (const marker of ["src", PLAN_ROOT, "e2e"]) {
|
|
57
58
|
try { if (statSync(join(d, marker)).isDirectory()) { roots.add(d); break; } } catch {}
|
|
58
59
|
}
|
|
59
60
|
let entries; try { entries = readdirSync(d).sort(); } catch { return; }
|
|
@@ -111,7 +112,7 @@ export function maskComments(text) {
|
|
|
111
112
|
export function scanExecution(scanRoot) {
|
|
112
113
|
const violations = [];
|
|
113
114
|
for (const croot of findConsumerRoots(scanRoot)) {
|
|
114
|
-
const ilFiles = walk(join(croot,
|
|
115
|
+
const ilFiles = walk(join(croot, PLAN_ROOT), (f) => /_interlockings[/\\].*\.ya?ml$/.test(f));
|
|
115
116
|
const declared = [...new Set(ilFiles.flatMap((f) => declaredValues(readText(f))))];
|
|
116
117
|
if (!declared.length) continue;
|
|
117
118
|
const rtFiles = walk(join(croot, "src/trains"), (f) => /\.(ts|tsx|mjs|js)$/.test(f))
|
|
@@ -28,6 +28,7 @@ import { join, sep, resolve } from "node:path";
|
|
|
28
28
|
const RULE = "coder.bun.interlocking-bilateral-binding";
|
|
29
29
|
const DEFAULT_EXCLUDES = ["_generated", "node_modules", "dist", "build", ".next"];
|
|
30
30
|
const PARALLEL_FIELDS = ["entrypoints", "runtime_exposure", "station_actions", "exposed_actions", "reachability"];
|
|
31
|
+
const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
|
|
31
32
|
|
|
32
33
|
// ── generic IO / masking ─────────────────────────────────────────────────────
|
|
33
34
|
|
|
@@ -147,7 +148,7 @@ function* walkDirs(root) {
|
|
|
147
148
|
function findConsumerRoots(scanRoot) {
|
|
148
149
|
const roots = new Set();
|
|
149
150
|
for (const d of walkDirs(scanRoot)) {
|
|
150
|
-
if (hasChildDir(d, "src") || hasChildDir(d,
|
|
151
|
+
if (hasChildDir(d, "src") || hasChildDir(d, PLAN_ROOT) || hasChildDir(d, "e2e")) roots.add(d);
|
|
151
152
|
}
|
|
152
153
|
return [...roots];
|
|
153
154
|
}
|
|
@@ -181,9 +182,9 @@ const isYaml = (f) => f.endsWith(".yaml") || f.endsWith(".yml");
|
|
|
181
182
|
const isTs = (f) => f.endsWith(".ts") || f.endsWith(".tsx");
|
|
182
183
|
|
|
183
184
|
function interlockingFiles(croot) {
|
|
184
|
-
const base = join(croot,
|
|
185
|
+
const base = join(croot, PLAN_ROOT, "_trains", "_interlockings");
|
|
185
186
|
const out = [...walkFiles(base, isYaml)];
|
|
186
|
-
const idx = join(croot,
|
|
187
|
+
const idx = join(croot, PLAN_ROOT, "_trains", "_interlockings.yaml");
|
|
187
188
|
try {
|
|
188
189
|
if (statSync(idx).isFile()) out.push(idx);
|
|
189
190
|
} catch {
|
|
@@ -377,7 +378,7 @@ function trainArtifactExists(croot, route) {
|
|
|
377
378
|
}
|
|
378
379
|
if (route.trainId) {
|
|
379
380
|
try {
|
|
380
|
-
return statSync(join(croot,
|
|
381
|
+
return statSync(join(croot, PLAN_ROOT, "_trains", `${route.trainId}.yaml`)).isFile();
|
|
381
382
|
} catch {
|
|
382
383
|
return false;
|
|
383
384
|
}
|
|
@@ -413,7 +414,7 @@ function scanConsumerRoot(croot) {
|
|
|
413
414
|
for (const rec of records) {
|
|
414
415
|
for (const route of rec.routes) {
|
|
415
416
|
if (trainArtifactExists(croot, route)) continue;
|
|
416
|
-
const target = route.trainPath || (route.trainId ?
|
|
417
|
+
const target = route.trainPath || (route.trainId ? `${PLAN_ROOT}/_trains/${route.trainId}.yaml` : "<no train_id>");
|
|
417
418
|
violations.push(
|
|
418
419
|
mk(
|
|
419
420
|
rel(rec.file, croot),
|
|
@@ -10,6 +10,7 @@ import { readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
|
10
10
|
import { join, sep } from "node:path";
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_EXCLUDES = ["_generated", "node_modules", "dist", "build", ".next"];
|
|
13
|
+
export const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
|
|
13
14
|
|
|
14
15
|
// Production runner symbols (core #1251 call model).
|
|
15
16
|
export const PROD_INTERLOCKING = "InterlockingRunner";
|
|
@@ -220,7 +221,7 @@ function* walkDirs(root) {
|
|
|
220
221
|
export function findConsumerRoots(scanRoot) {
|
|
221
222
|
const roots = new Set();
|
|
222
223
|
for (const d of walkDirs(scanRoot)) {
|
|
223
|
-
if (hasChildDir(d,
|
|
224
|
+
if (hasChildDir(d, PLAN_ROOT) || hasChildDir(d, "e2e") || hasChildDir(d, "src")) roots.add(d);
|
|
224
225
|
}
|
|
225
226
|
return [...roots];
|
|
226
227
|
}
|
|
@@ -254,9 +255,9 @@ const isYaml = (f) => f.endsWith(".yaml") || f.endsWith(".yml");
|
|
|
254
255
|
const isTs = (f) => f.endsWith(".ts") || f.endsWith(".tsx");
|
|
255
256
|
|
|
256
257
|
export function interlockingFiles(croot) {
|
|
257
|
-
const base = join(croot,
|
|
258
|
+
const base = join(croot, PLAN_ROOT, "_trains", "_interlockings");
|
|
258
259
|
const out = [...walkFiles(base, isYaml)];
|
|
259
|
-
const idx = join(croot,
|
|
260
|
+
const idx = join(croot, PLAN_ROOT, "_trains", "_interlockings.yaml");
|
|
260
261
|
try {
|
|
261
262
|
if (statSync(idx).isFile()) out.push(idx);
|
|
262
263
|
} catch {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// suite green. The runtime obeyed the new plan; no test looked.
|
|
17
17
|
import {
|
|
18
18
|
parseJsonEnv, readText, findConsumerRoots, interlockingFiles, e2eFiles,
|
|
19
|
-
parseInterlocking, tokenCovered, rel, mk,
|
|
19
|
+
parseInterlocking, tokenCovered, rel, mk, PLAN_ROOT,
|
|
20
20
|
} from "../_shared/interlocking.mjs";
|
|
21
21
|
import { writeFileSync, readdirSync, statSync } from "node:fs";
|
|
22
22
|
import { join } from "node:path";
|
|
@@ -69,7 +69,7 @@ export function declaresASequence(croot, trainId) {
|
|
|
69
69
|
if (st.isDirectory()) { if (name !== "_interlockings") rec(full); continue; }
|
|
70
70
|
if (/\.ya?ml$/.test(name)) found.push(full);
|
|
71
71
|
}
|
|
72
|
-
})(join(croot,
|
|
72
|
+
})(join(croot, PLAN_ROOT));
|
|
73
73
|
for (const f of found) {
|
|
74
74
|
const text = readText(f);
|
|
75
75
|
if (!text.includes(trainId)) continue;
|
|
@@ -25,6 +25,7 @@ import { readFileSync, statSync, readdirSync, writeFileSync } from "node:fs";
|
|
|
25
25
|
import { join, sep, basename } from "node:path";
|
|
26
26
|
|
|
27
27
|
export const DEFAULT_EXCLUDES = ["_generated", "node_modules", "dist", "build", ".next"];
|
|
28
|
+
export const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
|
|
28
29
|
|
|
29
30
|
// Structured-resolution model contract, TS camelCase, transcribed from core's own
|
|
30
31
|
// InterlockingResolution (src/atdd/runtime/interlocking/runner.py).
|
|
@@ -127,7 +128,7 @@ function* walkDirs(root) {
|
|
|
127
128
|
export function findConsumerRoots(scanRoot) {
|
|
128
129
|
const roots = new Set();
|
|
129
130
|
for (const d of walkDirs(scanRoot)) {
|
|
130
|
-
if (hasChildDir(d, "src") || hasChildDir(d,
|
|
131
|
+
if (hasChildDir(d, "src") || hasChildDir(d, PLAN_ROOT) || hasChildDir(d, "e2e")) roots.add(d);
|
|
131
132
|
}
|
|
132
133
|
return [...roots];
|
|
133
134
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
mk,
|
|
19
19
|
writeReport,
|
|
20
20
|
maskComments,
|
|
21
|
+
PLAN_ROOT,
|
|
21
22
|
} from "../_shared/interlocking.mjs";
|
|
22
23
|
|
|
23
24
|
const RULE = "coder.bun.journey-runner-boundary";
|
|
@@ -25,7 +26,7 @@ const roots = parseJsonEnv("ATDD_SCAN_ROOTS", []);
|
|
|
25
26
|
const violations = [];
|
|
26
27
|
|
|
27
28
|
function hasJourneyTopology(root) {
|
|
28
|
-
const base = join(root,
|
|
29
|
+
const base = join(root, PLAN_ROOT, "_journeys");
|
|
29
30
|
if (!existsSync(base)) return false;
|
|
30
31
|
const stack = [base];
|
|
31
32
|
while (stack.length) {
|
|
@@ -88,7 +89,7 @@ for (const scanRoot of roots) {
|
|
|
88
89
|
if (modules.length === 0) {
|
|
89
90
|
violations.push(mk(
|
|
90
91
|
RULE,
|
|
91
|
-
|
|
92
|
+
`${PLAN_ROOT}/_journeys`,
|
|
92
93
|
1,
|
|
93
94
|
0,
|
|
94
95
|
"missing-journey-runner: journey topology is declared but no JourneyRunner class exists under src/trains/",
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
mk,
|
|
14
14
|
writeReport,
|
|
15
15
|
maskComments,
|
|
16
|
+
PLAN_ROOT,
|
|
16
17
|
} from "../_shared/interlocking.mjs";
|
|
17
18
|
|
|
18
19
|
const RULE = "coder.bun.station-master-journey-routing";
|
|
@@ -24,7 +25,7 @@ function escapeRegExp(value) {
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
function journeyDocuments(croot) {
|
|
27
|
-
const base = join(croot,
|
|
28
|
+
const base = join(croot, PLAN_ROOT, "_journeys");
|
|
28
29
|
if (!existsSync(base)) return [];
|
|
29
30
|
const out = [], stack = [base];
|
|
30
31
|
while (stack.length) {
|
|
@@ -30,6 +30,7 @@ const RULE = "coder.bun.wagon-honours-its-contract";
|
|
|
30
30
|
const EXCLUDES = ["node_modules", "dist", "build", ".next", ".git", "_generated"];
|
|
31
31
|
const TS = /\.(ts|tsx|mjs|js)$/;
|
|
32
32
|
const TEST = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
33
|
+
const PLAN_ROOT = process.env.ATDD_PLAN_ROOT || "plan";
|
|
33
34
|
|
|
34
35
|
const read = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
|
|
35
36
|
|
|
@@ -61,7 +62,7 @@ function consumerRoots(root, excludes) {
|
|
|
61
62
|
let st;
|
|
62
63
|
try { st = statSync(d); } catch { return; }
|
|
63
64
|
if (!st.isDirectory()) return;
|
|
64
|
-
try { if (statSync(join(d,
|
|
65
|
+
try { if (statSync(join(d, PLAN_ROOT)).isDirectory()) { roots.push(d); return; } } catch {}
|
|
65
66
|
let entries;
|
|
66
67
|
try { entries = readdirSync(d).sort(); } catch { return; }
|
|
67
68
|
for (const n of entries) if (!excludes.includes(n)) rec(join(d, n));
|
|
@@ -111,7 +112,7 @@ const violations = [];
|
|
|
111
112
|
for (const root of parseJsonEnv("ATDD_SCAN_ROOTS", [])) {
|
|
112
113
|
for (const croot of consumerRoots(root, excludes)) {
|
|
113
114
|
const contracts = [];
|
|
114
|
-
for (const f of walk(join(croot,
|
|
115
|
+
for (const f of walk(join(croot, PLAN_ROOT), (p) => /\.ya?ml$/.test(p), excludes)) {
|
|
115
116
|
const c = wagonContract(read(f));
|
|
116
117
|
if (c) contracts.push({ file: f, ...c });
|
|
117
118
|
}
|
|
@@ -48,9 +48,9 @@ function planFiles(dir, excludes) {
|
|
|
48
48
|
// and WMBT documents, and a reference is resolvable if the plan states that URN
|
|
49
49
|
// anywhere. Over-accepting here is safe — the failure this rule exists to catch is a
|
|
50
50
|
// reference to something the plan never mentions at all.
|
|
51
|
-
function declaredUrns(croot, excludes) {
|
|
51
|
+
function declaredUrns(croot, excludes, planRoot) {
|
|
52
52
|
const urns = new Set();
|
|
53
|
-
for (const f of planFiles(join(croot,
|
|
53
|
+
for (const f of planFiles(join(croot, planRoot), excludes)) {
|
|
54
54
|
for (const m of read(f).matchAll(/\b((?:acc|wmbt):[a-z0-9][\w.-]*(?::[\w.-]+)?)/gi)) {
|
|
55
55
|
urns.add(m[1]);
|
|
56
56
|
}
|
|
@@ -58,13 +58,13 @@ function declaredUrns(croot, excludes) {
|
|
|
58
58
|
return urns;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function consumerRoots(root, excludes) {
|
|
61
|
+
function consumerRoots(root, excludes, planRoot) {
|
|
62
62
|
const roots = [];
|
|
63
63
|
(function rec(d) {
|
|
64
64
|
let st;
|
|
65
65
|
try { st = statSync(d); } catch { return; }
|
|
66
66
|
if (!st.isDirectory()) return;
|
|
67
|
-
try { if (statSync(join(d,
|
|
67
|
+
try { if (statSync(join(d, planRoot)).isDirectory()) { roots.push(d); return; } } catch {}
|
|
68
68
|
let entries;
|
|
69
69
|
try { entries = readdirSync(d).sort(); } catch { return; }
|
|
70
70
|
for (const n of entries) if (!excludes.includes(n)) rec(join(d, n));
|
|
@@ -79,10 +79,11 @@ if (!reportPath) {
|
|
|
79
79
|
}
|
|
80
80
|
const excludes = [...DEFAULT_EXCLUDES, ...parseJsonEnv("ATDD_SCAN_EXCLUDES", [])];
|
|
81
81
|
const violations = [];
|
|
82
|
+
const planRoot = process.env.ATDD_PLAN_ROOT || "plan";
|
|
82
83
|
|
|
83
84
|
for (const root of parseJsonEnv("ATDD_SCAN_ROOTS", [])) {
|
|
84
|
-
for (const croot of consumerRoots(root, excludes)) {
|
|
85
|
-
const declared = declaredUrns(croot, excludes);
|
|
85
|
+
for (const croot of consumerRoots(root, excludes, planRoot)) {
|
|
86
|
+
const declared = declaredUrns(croot, excludes, planRoot);
|
|
86
87
|
if (!declared.size) continue; // nothing declared: nothing to resolve against
|
|
87
88
|
for (const file of walkTests(croot, excludes)) {
|
|
88
89
|
const H = parseHeader(read(file));
|
package/integrity.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.6.0",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
6
|
-
"README.md": "
|
|
6
|
+
"README.md": "622e7768f567099b0e30ac82f2053c165b8023e571966fc327f77f985ae1be0d",
|
|
7
7
|
"bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
|
|
8
8
|
"conventions/coder.bun/coder.bun.boundaries-http-client.convention.yaml": "b846bb6ced2c4c967466520d52b00ff1ca802dafcdbb2ec2adbd0ce19e752a28",
|
|
9
9
|
"conventions/coder.bun/coder.bun.commons-application-no-integration.convention.yaml": "f63e7f6442bcdf5cf79f58a091547a1a990d0ee7c006b797fd5187d6f8bf8712",
|
|
@@ -124,8 +124,28 @@
|
|
|
124
124
|
"conventions/traceability/traceability.source.tested-by-present.convention.yaml": "27a2340a9dae9eee67a3fbdcea0d2f8f35f222f22dbce7f84b2a0c6ab6321018",
|
|
125
125
|
"conventions/traceability/traceability.source.tested-by-resolves.convention.yaml": "ad4516670934dc82f1ebf9dc057110313059110302f88b4747dde89dd060870b",
|
|
126
126
|
"conventions/traceability/traceability.test.binding-resolves.convention.yaml": "03c118f498b1e824d457d25c060c8f9689ccbcca49c58d8bcd158e3cd3144ee9",
|
|
127
|
+
"detectors/atdd_topology/atdd.implementation.yaml": "e5121dfd24c8dff4a3d471c90946e995c1efb3d56433d5739c289e77c7dd13ec",
|
|
128
|
+
"detectors/atdd_topology/detect.mjs": "ab79df467d12423c8c851fae198b97adaef6fc5b66681b2460b8aeb84a210b36",
|
|
129
|
+
"detectors/atdd_topology/fixtures/clean/atdd-bun.yaml": "85049c05fb624037e1e2200a117b07d6c8d089d4e81fd784ade15a860a444f59",
|
|
130
|
+
"detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts": "be769e192e8165d4d36be743e8df578c1790b900c6b89acc7d2046ba40105135",
|
|
131
|
+
"detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts": "330dd13694d4b53b1ee261dd8d37e0610ef0cf4e92bc70ccf3039878bfee90a7",
|
|
132
|
+
"detectors/atdd_topology/fixtures/clean/implementation/orders/features/place-order/domain/order.ts": "b81c0630a2c518749ed6d81741ba4e1503ffdbc64f232747518a17e8e8a46927",
|
|
133
|
+
"detectors/atdd_topology/fixtures/clean/plan/_journeys/checkout.yaml": "c61021d0bf2f7f3bfe306b18c4b34563292de02acce47326db71ac4f8d67bc8d",
|
|
134
|
+
"detectors/atdd_topology/fixtures/clean/plan/_trains/_interlockings/checkout.yaml": "99ee3ef93e3098943878c947a584d93fec1407f0a5bd85c5383129229a98d0bc",
|
|
135
|
+
"detectors/atdd_topology/fixtures/clean/plan/_trains/checkout.yaml": "d022cd946cced5961d16535f93cc875b76e2cad872625e5e040242810f8cfad1",
|
|
136
|
+
"detectors/atdd_topology/fixtures/clean/plan/orders/E001.yaml": "94797b2afa85962a16673a92d15bf4796fd00cf2223c9d4106e06ba28c89599e",
|
|
137
|
+
"detectors/atdd_topology/fixtures/clean/plan/orders/_orders.yaml": "33f0f30b31e3fb1bedf1ba8d2071dc7cea02749b48d18196038c9d2321aaf628",
|
|
138
|
+
"detectors/atdd_topology/fixtures/clean/plan/orders/place-order.yaml": "cccbad80a40da7bbb52c475fe73f5cd6e523994b428dc133aefc37225b04a80c",
|
|
139
|
+
"detectors/atdd_topology/fixtures/clean/tests/wagons/orders/features/place-order/unit/order.test.ts": "01a9a560d3d977346e39b88a88e7112d9f47f02e8dc8d995ba5fb45f77842153",
|
|
140
|
+
"detectors/atdd_topology/fixtures/dirty/plan/_journeys/checkout.yaml": "407eeb607a94b08d3d48da6b28b6c6102ffe2f5d11a58b9557a3f3a4d1dda1bd",
|
|
141
|
+
"detectors/atdd_topology/fixtures/dirty/plan/orders/E001.yaml": "c3e3fc4784a034a4af8fd5babb6cfae39047e58131d204f76ad838a199ba92f9",
|
|
142
|
+
"detectors/atdd_topology/fixtures/dirty/plan/orders/_orders.yaml": "1d533ed773dd1a6317dfd24abf6aeddd13469321bd07ae60743ee9af58b6531a",
|
|
143
|
+
"detectors/atdd_topology/fixtures/dirty/plan/orders/features/place-order.yaml": "cccbad80a40da7bbb52c475fe73f5cd6e523994b428dc133aefc37225b04a80c",
|
|
144
|
+
"detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/forged.ts": "313bff608d93a1a6790932e21d17b4582160df3b4f04642f1cbb04a67fc2131d",
|
|
145
|
+
"detectors/atdd_topology/fixtures/dirty/src/wagons/orders/features/place-order/domain/order.ts": "6fdaad52671ef25b47d93f9b4e97e12c62eb78f5b4cde727435c3ac135a1c0e5",
|
|
146
|
+
"detectors/atdd_topology/fixtures/dirty/tests/wagons/orders/features/place-order/unit/order.test.ts": "f117e6c8b034700e1f00889bea48cb1528f0003d699cfd3e279cd9164b264a38",
|
|
127
147
|
"detectors/atdd_traceability_closure/atdd.implementation.yaml": "82e56cc85539476e42fc8d58e718bc998f1eff6c325a0cd240c0284625834d68",
|
|
128
|
-
"detectors/atdd_traceability_closure/detect.mjs": "
|
|
148
|
+
"detectors/atdd_traceability_closure/detect.mjs": "b8c454d7e136cdcc82524fea407490eefc8c2b8c9448831d94d51253f00a38a9",
|
|
129
149
|
"detectors/atdd_traceability_closure/fixtures/clean/plan/orders/E001.yaml": "0ddad928dde10c2ab690558259a6975125af540a1f30eca9aa0c615bf0ad69fb",
|
|
130
150
|
"detectors/atdd_traceability_closure/fixtures/clean/src/order.ts": "efe6db7e3f723c7b6096abdf58dec9c88b27ec8978d434a10135b3e77bfce321",
|
|
131
151
|
"detectors/atdd_traceability_closure/fixtures/clean/test/order.test.ts": "bee451198d472749616f85c1f1e97c0a06ad28255b9a6a2cc8701bd2e8d56562",
|
|
@@ -268,8 +288,8 @@
|
|
|
268
288
|
"detectors/bun_green_traceability_detector/fixtures/dirty/green_urn_wagon_feature/OrderRow.ts": "eed3e99b3a582a41e7179228ca8c33987fdf0ca1c06d78710951057a2355d902",
|
|
269
289
|
"detectors/bun_green_traceability_detector/urn_header.mjs": "004b0ed1f71ec476fb57a959d302da4b5869ff91be049865a806bedca40f55b9",
|
|
270
290
|
"detectors/bun_interlocking_binding/atdd.implementation.yaml": "8df233a7c337d38ab4fc92a309273b91fe713b27507a4b2cfa66ffc7a948d520",
|
|
271
|
-
"detectors/bun_interlocking_binding/checks/interlocking_runtime_executes.mjs": "
|
|
272
|
-
"detectors/bun_interlocking_binding/detect.mjs": "
|
|
291
|
+
"detectors/bun_interlocking_binding/checks/interlocking_runtime_executes.mjs": "ea4c32914a007962012e9062cc64063fa939ff0a59c07960c34872d058720955",
|
|
292
|
+
"detectors/bun_interlocking_binding/detect.mjs": "42767b7d329b5b2623de37f450c416fe6feb4a7ae737201376e93aa74d493dea",
|
|
273
293
|
"detectors/bun_interlocking_binding/fixtures/clean/e2e/interlockings/match-resolution/trace.test.ts": "5dadc041ca9825f404340ca428e14bbff5e00824ca6c25464f21cd2ce56044d8",
|
|
274
294
|
"detectors/bun_interlocking_binding/fixtures/clean/plan/_trains/_interlockings/match-resolution.yaml": "11884309beb9bcceccfeda7917ea73d51a530366db5eb60772b03cf5eeb239cd",
|
|
275
295
|
"detectors/bun_interlocking_binding/fixtures/clean/plan/_trains/train:match:match-resolution-standard.yaml": "a7067661a723e02e0e844e6b90ad187f620dd328e1b9be45163c04db1919ce77",
|
|
@@ -319,14 +339,14 @@
|
|
|
319
339
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/server.ts": "d5bc34c5b1b2b0a511e0c4604ce6f9c77491dca9aee994be720540f3052babc1",
|
|
320
340
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/interlocking.ts": "9bcfdb4ecff6efd1f658b9fd587342615179924e25468c75daa07c2626d4d8ab",
|
|
321
341
|
"detectors/bun_interlocking_binding/fixtures/dirty_transcribed_runtime/src/trains/runner.ts": "0e4b885e50f12cc2a8c8f6740bf83e7b2e7137a28e1e143586e9a8550ea5bd45",
|
|
322
|
-
"detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "
|
|
342
|
+
"detectors/bun_interlocking_coverage/_shared/interlocking.mjs": "dd484cec27919f342aa5a3ba631cf197acab4f359e7fe0ffd1123f968954fc52",
|
|
323
343
|
"detectors/bun_interlocking_coverage/atdd.implementation.yaml": "f160517a8138ba3ca05e3d130e6fbfb796e643376f25743fdef4fc210196525b",
|
|
324
344
|
"detectors/bun_interlocking_coverage/checks/_map.json": "0a218ea319cc6258af7bd721c2154cb9561fe67e80d80ef32d08fc77bc9403c3",
|
|
325
345
|
"detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs": "136eb0955052f2485740adfb888f5ba6f1414dcdb53d505034b8db40420a611a",
|
|
326
346
|
"detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs": "55716c706818d3f724cdd5f1f16a67b92dacdc4bb4a2d23a037039ddd869acea",
|
|
327
347
|
"detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs": "ea4b0155c4f151a36979d850aa2482901b37eacc0fe01cecc0a736089a3e15ce",
|
|
328
348
|
"detectors/bun_interlocking_coverage/checks/interlocking_trace_binds.mjs": "61dcb8a8d04e9764aab7920f346bd3684fc98e2c294aa5c58eefec555145a1d0",
|
|
329
|
-
"detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs": "
|
|
349
|
+
"detectors/bun_interlocking_coverage/checks/interlocking_train_sequence.mjs": "ab00524af6cce2a95c3a1232f2e6304c761533c2ac3f42d34683b2c0dc5f9c6a",
|
|
330
350
|
"detectors/bun_interlocking_coverage/detect.mjs": "a124418dc29aaf8fda9cd09427230d25768206b3f563d516774667e8cf75b66e",
|
|
331
351
|
"detectors/bun_interlocking_coverage/fixtures/clean/interlocking_production_runner/e2e/interlockings/match-resolution/routes.test.ts": "ba6ea85b3fbe15e3c49332da3ad2921a0ff3436a6fe3b5a5035fb35fdc874720",
|
|
332
352
|
"detectors/bun_interlocking_coverage/fixtures/clean/interlocking_production_runner/e2e/interlockings/match-resolution/trace.test.ts": "aee4ea93ceb8636846344e915cd7e66f7e8f99ba08596f83266590fa2b9ff58e",
|
|
@@ -367,7 +387,7 @@
|
|
|
367
387
|
"detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_train_sequence/plan/_trains/_interlockings/match-resolution.yaml": "9e0f02cb0889826f2ac2fb88cd8c118c97d4321dcc5fccc0b767a511606841e6",
|
|
368
388
|
"detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_train_sequence/plan/_trains/train:match:match-resolution-standard.yaml": "9ea90f674e5fc565d8893e74ad2e47f273c971ca29284dbce58a366a3e832428",
|
|
369
389
|
"detectors/bun_interlocking_coverage/fixtures/dirty/interlocking_train_sequence/plan/_trains/train:match:match-resolution-timeout.yaml": "aa1b7682f99f3a9a25ec0d3d03f3798ddda8c74eb728e148b38197ddab292493",
|
|
370
|
-
"detectors/bun_interlocking_infrastructure/_shared/interlocking.mjs": "
|
|
390
|
+
"detectors/bun_interlocking_infrastructure/_shared/interlocking.mjs": "433977b3b8242c0f061924c53e9f7488cd5cc7368edacbe007c14d749cc805f4",
|
|
371
391
|
"detectors/bun_interlocking_infrastructure/atdd.implementation.yaml": "fa7a1de97b19dff6515100131a3b6fc2dacf2766cd2bd44bc9dd792f6b01590d",
|
|
372
392
|
"detectors/bun_interlocking_infrastructure/checks/_map.json": "5f86408f98de8f1562e679e2bd3a2194a8eb297fecf677ca774f19905942ec71",
|
|
373
393
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_delegates.mjs": "1aa4c87e234643e99ba5a2ff1fab3db8bfc7cbc9618c8e1a014eef2c11cce9c7",
|
|
@@ -375,9 +395,9 @@
|
|
|
375
395
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_resolution_model.mjs": "e761495b19eed0af09fcb4db34126d0e2398e91f086e04f5ef84d0b5c1c3ec16",
|
|
376
396
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_runner_exists.mjs": "6b63d3f26a51f2dd27847edd27c4d9c9f15c1f02747dba611cebccfc76f13169",
|
|
377
397
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs": "0221f5a8b49763388affefff670bc2a452e539e7658b08f6230a52f13edd36bd",
|
|
378
|
-
"detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "
|
|
379
|
-
"detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "
|
|
380
|
-
"detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs": "
|
|
398
|
+
"detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "8c7225f5ee387cbf7ba1e536f91c67c331a86025b4b2b38b961488d949cbdb21",
|
|
399
|
+
"detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "b297f3f677e0239c5d14cf36218fc39ec23024a0f29620dd02945601aad20516",
|
|
400
|
+
"detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs": "728f710827295472dadec182fcdf6c3e8fc44cdedeae53a669754be8435fdd7f",
|
|
381
401
|
"detectors/bun_interlocking_infrastructure/detect.mjs": "54c02f428596e057f828def0d68360b220b55dbf962b46694a21bc423ce57218",
|
|
382
402
|
"detectors/bun_interlocking_infrastructure/fixtures/clean/interlocking_delegates/server.ts": "36913357fdd3c5e375829a0554c605d70f5c05e2e83349f47505741598f6eb15",
|
|
383
403
|
"detectors/bun_interlocking_infrastructure/fixtures/clean/interlocking_delegates/src/match/wagon.ts": "ea202fa15fdf9d1391b1ae23343c9d1b09fcf8ef14f1dccfb8114ffaedea97c1",
|
|
@@ -476,7 +496,7 @@
|
|
|
476
496
|
"detectors/bun_tester_discipline_detector/checks/_map.json": "d28273b975c4fbcda64026178b918dc9a84315d6312f29ae5221a0a9cb4c0b44",
|
|
477
497
|
"detectors/bun_tester_discipline_detector/checks/t_acceptance_binding.mjs": "17861380275433ff79e191803456417454e2efa23801d9ac72bf6f8206337522",
|
|
478
498
|
"detectors/bun_tester_discipline_detector/checks/t_acceptance_covers_tag.mjs": "2116c9936cb5863df643b968dece23c29e145ede30cf7c7862e5c17ac7beb6dd",
|
|
479
|
-
"detectors/bun_tester_discipline_detector/checks/t_acceptance_resolves.mjs": "
|
|
499
|
+
"detectors/bun_tester_discipline_detector/checks/t_acceptance_resolves.mjs": "fe7047cc069be0d8ad85ebb701916a30eca667483d34bc75d4b95baf3b26c168",
|
|
480
500
|
"detectors/bun_tester_discipline_detector/checks/t_isolation_no_live_state.mjs": "de2c396b83bc2e5317f6b8eee31c98103e7360d1cd25e7c1c53db6ff7627203f",
|
|
481
501
|
"detectors/bun_tester_discipline_detector/checks/t_red_behavioral.mjs": "ac0b77bbf10a08d991272fbce41db2a34187c8f5faf1e04c5b1a858ecfbcc4ef",
|
|
482
502
|
"detectors/bun_tester_discipline_detector/checks/t_red_fails_first.mjs": "06cb66f5ccf7d59e1d21f6d3a9db29ca7f3182c096b17ddb50b85b319b97208b",
|
|
@@ -1050,17 +1070,18 @@
|
|
|
1050
1070
|
"src/ci.ts": "899125ec76d14f7d0126a5db2249913e373e3a325cbc98d7f053e1351bdf47af",
|
|
1051
1071
|
"src/cli.ts": "bb2047cee60f48bfe3eaee4c1b3dcd3538a0e1edc170cf58e87686117bea07a7",
|
|
1052
1072
|
"src/docs-capability.ts": "65ef91658664b41b78184b395e57e5d28bda16128caa9e314f26e63568abc6cd",
|
|
1053
|
-
"src/enforce.ts": "
|
|
1054
|
-
"src/hooks.ts": "
|
|
1055
|
-
"src/index.ts": "
|
|
1073
|
+
"src/enforce.ts": "43ef276e1c4465ec32e5a431d9a5b1825c6168180e695280fd6d208fd583bac9",
|
|
1074
|
+
"src/hooks.ts": "54d70d7d6923f1e39b259e3b9f377c43609aecdc64ff4d1232da595950d7b1aa",
|
|
1075
|
+
"src/index.ts": "2743b7969526d0aaf21179048e9896141e97d89c45c4a503b795f41fed57f6cc",
|
|
1056
1076
|
"src/integrity.ts": "48d7adce486ece1885a95aac6867d286bb972d7a97bdbc32222fd6d75c2e2b49",
|
|
1057
1077
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
|
1058
|
-
"src/planner-kernel.ts": "
|
|
1059
|
-
"src/planner-schema-validator.ts": "
|
|
1060
|
-
"src/planner-validators.ts": "
|
|
1078
|
+
"src/planner-kernel.ts": "87ffbabc60d7868df52e6e060fcb36b41e90c9d6a47fa9a99f16aa263d61a528",
|
|
1079
|
+
"src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
|
|
1080
|
+
"src/planner-validators.ts": "99adeb65f579af923c11f21a8f55cfb9993b007fd8df59c5f764696b55379cfc",
|
|
1061
1081
|
"src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
|
|
1062
1082
|
"src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
|
|
1063
1083
|
"src/setup.ts": "0a6f1a9aeff31dcf17f6bd036168b909c1841c11bbbb452105d8debf64a6bc78",
|
|
1084
|
+
"src/topology.ts": "ff86fc7923793c727196e6f8459db4f071fcaa6844268c2e3b232bb18801c43d",
|
|
1064
1085
|
"templates/agents/AGENTS.block.md": "bfd8769bc42b8c4c91a70777e056624e37acb1037c7fd1497fcb59f007567336",
|
|
1065
1086
|
"templates/agents/atdd-bun.integrity.test.ts": "dec6f6e5f65a08d9512fc703b367c163f8c9da1f6aa7fd466835c1ec30348a41",
|
|
1066
1087
|
"templates/agents/atdd/SKILL.md": "b621abe22851a75b30fc0e6c339a2c0a3ed6bbf78b8c17000a4ce301e05df96d",
|
package/package.json
CHANGED
package/src/enforce.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
|
+
import { topologyFor } from "./topology";
|
|
4
5
|
|
|
5
|
-
export type Profile = "traceability" | "docs" | "planner" | "coder" | "tester" | "security" | "architecture" | "metrics" | "runtime" | "interlocking" | "htmx" | "design" | "all";
|
|
6
|
+
export type Profile = "traceability" | "topology" | "docs" | "planner" | "coder" | "tester" | "security" | "architecture" | "metrics" | "runtime" | "interlocking" | "htmx" | "design" | "all";
|
|
6
7
|
|
|
7
8
|
export type Violation = {
|
|
8
9
|
rule_id: string;
|
|
@@ -22,10 +23,11 @@ export type EnforcementConfig = {
|
|
|
22
23
|
|
|
23
24
|
const profiles: Record<Exclude<Profile, "all">, string[]> = {
|
|
24
25
|
traceability: ["atdd_traceability_closure"],
|
|
26
|
+
topology: ["atdd_topology"],
|
|
25
27
|
docs: ["planner_docs_capability"],
|
|
26
|
-
planner: ["planner_plan_integrity", "planner_schema_validation", "planner_static_validators"],
|
|
27
|
-
coder: ["bun_green_traceability_detector", "bun_clean_architecture_detector", "bun_ts_metrics_detector", "bun_fullstack_detector", "bun_design_system_detector", "bun_responsive_detector"],
|
|
28
|
-
tester: ["bun_tester_discipline_detector", "htmx_e2e_detector"],
|
|
28
|
+
planner: ["planner_plan_integrity", "planner_schema_validation", "planner_static_validators", "atdd_topology"],
|
|
29
|
+
coder: ["bun_green_traceability_detector", "bun_clean_architecture_detector", "bun_ts_metrics_detector", "bun_fullstack_detector", "bun_design_system_detector", "bun_responsive_detector", "atdd_topology"],
|
|
30
|
+
tester: ["bun_tester_discipline_detector", "htmx_e2e_detector", "atdd_topology"],
|
|
29
31
|
security: ["bun_security_hygiene_detector"],
|
|
30
32
|
architecture: ["bun_clean_architecture_detector"],
|
|
31
33
|
metrics: ["bun_ts_metrics_detector"],
|
|
@@ -63,7 +65,7 @@ export async function runImplementation(
|
|
|
63
65
|
const scratch = await mkdtemp(join(tmpdir(), "atdd-bun-"));
|
|
64
66
|
const report = join(scratch, "violations.json");
|
|
65
67
|
try {
|
|
66
|
-
const bun = Bun.which("bun") ?? process.execPath;
|
|
68
|
+
const bun = Bun.which("bun") ?? process.execPath, topology = await topologyFor(config.scanRoots[0] ?? process.cwd());
|
|
67
69
|
const child = Bun.spawn({
|
|
68
70
|
cmd: [bun, detector],
|
|
69
71
|
cwd: config.scanRoots[0] ?? process.cwd(),
|
|
@@ -73,6 +75,7 @@ export async function runImplementation(
|
|
|
73
75
|
...process.env,
|
|
74
76
|
ATDD_SCAN_ROOTS: JSON.stringify(config.scanRoots),
|
|
75
77
|
ATDD_SCAN_EXCLUDES: JSON.stringify(config.excludes),
|
|
78
|
+
ATDD_PLAN_ROOT: topology.planRoot,
|
|
76
79
|
ATDD_VIOLATIONS_REPORT: report,
|
|
77
80
|
},
|
|
78
81
|
});
|
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 };
|
|
@@ -65,7 +66,7 @@ function structuralRefs(artifact: PlanArtifact): string[] {
|
|
|
65
66
|
/** Load plan artifacts with Bun's native YAML parser. It is read-only and deliberately
|
|
66
67
|
* excludes authoring/session/store behavior from ATDD core. */
|
|
67
68
|
export async function loadPlan(root = process.cwd()): Promise<PlanGraph> {
|
|
68
|
-
const absolute = resolve(root), plan = join(absolute,
|
|
69
|
+
const absolute = resolve(root), topology = await topologyFor(absolute), plan = join(absolute, topology.planRoot), artifacts: PlanArtifact[] = [], findings: PlanFinding[] = [];
|
|
69
70
|
for (const path of await walk(plan)) {
|
|
70
71
|
const file = relative(absolute, path).replaceAll("\\", "/");
|
|
71
72
|
try {
|
|
@@ -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
|
+
}
|