@afokapu/atdd-bun 0.9.1 → 0.9.3
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 +7 -2
- package/conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml +2 -0
- package/conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml +2 -0
- package/detectors/atdd_topology/detect.mjs +10 -4
- package/detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs +3 -0
- package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +3 -11
- package/integrity.json +9 -9
- package/package.json +1 -1
- package/src/enforce.ts +5 -2
- package/src/hooks.ts +11 -2
package/README.md
CHANGED
|
@@ -52,7 +52,7 @@ registerEnforcementTest({ root: import.meta.dir + "/..", profiles: ["traceabilit
|
|
|
52
52
|
| Profile | Checks |
|
|
53
53
|
|---|---|
|
|
54
54
|
| `traceability` | acceptance → Bun test → source closure: every acceptance tested, every binding and `Tested-By` resolving |
|
|
55
|
-
| `topology` | feature decomposition and the plan, source, test and E2E locations |
|
|
55
|
+
| `topology` | feature decomposition and the plan, source, test and E2E locations. Missing source, tests and E2E suites are reported only when a run also selects `coder` or `tester`, so a repository in the PLAN stage can enforce `planner` and `topology` before RED |
|
|
56
56
|
| `planner` | schemas for every plan artifact, graph integrity, the scoped planner rules |
|
|
57
57
|
| `telemetry` | the telemetry tracking plan: item shape, path-mirrored identity and versioning under `telemetry/`, wagon ownership of logical artifacts, the per-acceptance telemetry decision, metric label cardinality, source `Telemetry:` references, raw-string and forbidden-property emission, the vendor-SDK boundary around the TelemetryPort, and telemetry tests that bind the acceptance and item, assert the exact identity on a captured sink, cover every required item, and exercise declared timing semantics |
|
|
58
58
|
| `delivery` | the review record of each tranche under `docs/delivery/tranches/`: allowed author and reviewer models with recorded fallbacks, reviewer independence, every finding fixed, withdrawn after one dispute or ruled on by a human, every configured stage approved, and, at the gate, no change without a record and a merged head that contains exactly the approved commit. Inert until adopted |
|
|
@@ -173,7 +173,12 @@ behind); it does not verify them
|
|
|
173
173
|
cryptographically.
|
|
174
174
|
|
|
175
175
|
The hooks enforce protected-branch blocking, micro-commit limits, mass-delete approval and
|
|
176
|
-
validation of the affected area. Git can bypass them, so CI is the authority.
|
|
176
|
+
validation of the affected area. Git can bypass them, so CI is the authority. The changed-line
|
|
177
|
+
limit counts a moved file by the edits it carries, so relocating a directory is not measured as
|
|
178
|
+
rewriting it; mass-delete and registry-removal approval still count a move in full.
|
|
179
|
+
The journey view `atdd-bun docs journeys` generates is exempt from the size caps, since its index
|
|
180
|
+
alone can exceed them, and never from the `docs` profile, which refuses a copy that is not exactly
|
|
181
|
+
what the plan generates.
|
|
177
182
|
|
|
178
183
|
## Agents and integrity
|
|
179
184
|
|
|
@@ -42,6 +42,8 @@ content:
|
|
|
42
42
|
calls, and train.sequence executor loops from JourneyRunner.
|
|
43
43
|
exceptions:
|
|
44
44
|
- Consumers with no plan/_journeys topology carry no JourneyRunner obligation.
|
|
45
|
+
- Consumers with no Station Master composition root yet (a repository in the PLAN stage) carry no
|
|
46
|
+
JourneyRunner obligation, as coder.bun.interlocking-runner-exists owes no InterlockingRunner before one.
|
|
45
47
|
metadata:
|
|
46
48
|
aliases:
|
|
47
49
|
- BUN-JOURNEY-RUNNER-BOUNDARY-001
|
|
@@ -36,6 +36,8 @@ content:
|
|
|
36
36
|
declared journey document and dispatch it through JourneyRunner.
|
|
37
37
|
exceptions:
|
|
38
38
|
- Journeys with entrypoint.exposed:false are intentionally not Station-Master-reachable.
|
|
39
|
+
- Consumers with no Station Master composition root yet (a repository in the PLAN stage) carry no
|
|
40
|
+
reachability obligation, as for exposed interlockings.
|
|
39
41
|
metadata:
|
|
40
42
|
aliases:
|
|
41
43
|
- BUN-STATION-MASTER-JOURNEY-ROUTING-001
|
|
@@ -12,6 +12,12 @@ const slug = "[a-z][a-z0-9-]*";
|
|
|
12
12
|
const componentUrn = new RegExp(`^component:(${slug}):(${slug}):[A-Za-z0-9.]+:(frontend|backend):(domain|application|integration|presentation)$`);
|
|
13
13
|
const testUrn = new RegExp(`^test:(${slug}):(${slug}):([A-Za-z0-9][A-Za-z0-9._-]*)$`);
|
|
14
14
|
const violations = [];
|
|
15
|
+
// Missing evidence is due only when a run asks for the stages that produce it. A planning run (planner or
|
|
16
|
+
// topology alone) still checks where every artifact lives and how every existing test binds, but it does not
|
|
17
|
+
// demand source, tests or E2E suites the lifecycle has not reached: RED writes the tests and GREEN the source.
|
|
18
|
+
// A run that names no profiles (a direct call) keeps the old, fail-closed behaviour.
|
|
19
|
+
const requestedProfiles = process.env.ATDD_PROFILES ? JSON.parse(process.env.ATDD_PROFILES) : null;
|
|
20
|
+
const evidenceDue = !Array.isArray(requestedProfiles) || requestedProfiles.some(profile => ["coder", "tester", "all"].includes(profile));
|
|
15
21
|
const rel = (root, path) => relative(root, path).split(sep).join("/");
|
|
16
22
|
const read = path => { try { return readFileSync(path, "utf8"); } catch { return ""; } };
|
|
17
23
|
const text = (data, key) => data && typeof data === "object" && !Array.isArray(data) && typeof data[key] === "string" ? data[key] : "";
|
|
@@ -104,8 +110,8 @@ for (const root of roots) {
|
|
|
104
110
|
}
|
|
105
111
|
for (const feature of features) if (list(feature.data, "wmbts").length) {
|
|
106
112
|
const key = `${feature.wagon}:${feature.slug}`;
|
|
107
|
-
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}/`);
|
|
108
|
-
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`);
|
|
113
|
+
if (evidenceDue && !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}/`);
|
|
114
|
+
if (evidenceDue && !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`);
|
|
109
115
|
}
|
|
110
116
|
const e2eRoot = join(root, cfg.e2e_root);
|
|
111
117
|
// A browser test is the E2E proof for a frontend behavior. It deliberately
|
|
@@ -148,7 +154,7 @@ for (const root of roots) {
|
|
|
148
154
|
const expected = join(e2eRoot, "interlockings", id, `${routeId}.routes.test.ts`);
|
|
149
155
|
interlockingRoutes.add(rel(root, expected));
|
|
150
156
|
const browserEligible = frontendInterlockings.has(text(interlocking.data, "interlocking_id"));
|
|
151
|
-
if (!existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
|
|
157
|
+
if (evidenceDue && !existsSync(expected) && !(browserEligible && browserTrains.has(text(route, "train_id")))) add("atdd-bun.topology.e2e-location", root, interlocking.path, browserEligible
|
|
152
158
|
? `${text(interlocking.data, "interlocking_id")} route ${routeId} requires ${rel(root, expected)} or a Playwright E2E spec bound to Train: ${text(route, "train_id")}`
|
|
153
159
|
: `${text(interlocking.data, "interlocking_id")} route ${routeId} is not on an exposed frontend journey and requires ${rel(root, expected)}`);
|
|
154
160
|
}
|
|
@@ -157,7 +163,7 @@ for (const root of roots) {
|
|
|
157
163
|
const entry = journey.data.entrypoint, id = text(journey.data, "journey_id").slice(8), path = join(e2eRoot, "journeys", `${id}.journey.test.ts`);
|
|
158
164
|
const reachable = journeyReachability(journey), journeyTrains = new Set([...reachable].flatMap(interlockingId => list(interlockings.get(interlockingId)?.data, "routes").map(route => text(route, "train_id")).filter(Boolean)));
|
|
159
165
|
const browserEligible = entry && typeof entry === "object" && hasFrontendSurface(entry);
|
|
160
|
-
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
|
|
166
|
+
if (evidenceDue && entry && typeof entry === "object" && entry.exposed === true && !existsSync(path) && !(browserEligible && browserJourneys.has(`journey:${id}`))) add("atdd-bun.topology.e2e-location", root, journey.path, browserEligible
|
|
161
167
|
? `exposed journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts or a Playwright E2E spec bound to Journey: journey:${id}`
|
|
162
168
|
: `exposed backend journey:${id} requires ${cfg.e2e_root}/journeys/${id}.journey.test.ts`);
|
|
163
169
|
else if (entry && typeof entry === "object" && entry.exposed === true && existsSync(path)) {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
parseJsonEnv,
|
|
7
7
|
readText,
|
|
8
8
|
findConsumerRoots,
|
|
9
|
+
appFile,
|
|
9
10
|
runtimeFiles,
|
|
10
11
|
referencesToken,
|
|
11
12
|
importsWagon,
|
|
@@ -87,6 +88,8 @@ for (const scanRoot of roots) {
|
|
|
87
88
|
.filter(item => /\bclass\s+JourneyRunner\b/.test(maskComments(item.text)));
|
|
88
89
|
|
|
89
90
|
if (modules.length === 0) {
|
|
91
|
+
// As interlocking-runner-exists: the runner is owed once a Station Master exists to call it, not before.
|
|
92
|
+
if (!appFile(croot)) continue;
|
|
90
93
|
violations.push(mk(
|
|
91
94
|
RULE,
|
|
92
95
|
`${PLAN_ROOT}/_journeys`,
|
|
@@ -62,17 +62,9 @@ for (const scanRoot of roots) {
|
|
|
62
62
|
if (journeys.length === 0) continue;
|
|
63
63
|
|
|
64
64
|
const app = appFile(croot);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
journey.file,
|
|
69
|
-
1,
|
|
70
|
-
0,
|
|
71
|
-
"journey-station-missing: exposed journey has no server.ts Station Master composition root",
|
|
72
|
-
"",
|
|
73
|
-
));
|
|
74
|
-
continue;
|
|
75
|
-
}
|
|
65
|
+
// As for interlockings: with no Station Master composition root yet (a repository in the PLAN stage),
|
|
66
|
+
// there is nothing to route through, so reachability is not yet due.
|
|
67
|
+
if (!app) continue;
|
|
76
68
|
|
|
77
69
|
const text = readText(app);
|
|
78
70
|
const appPath = relative(croot, app).replaceAll("\\", "/");
|
package/integrity.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.9.
|
|
2
|
+
"version": "0.9.3",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
6
|
-
"README.md": "
|
|
6
|
+
"README.md": "fefe75a32bc73ed614284da8b37afef26d0ff1d994ab41a58303b4ce8571a1cd",
|
|
7
7
|
"bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
|
|
8
8
|
"conventions/atdd-bun.planner/atdd-bun.planner.acceptance-identity.convention.yaml": "81c5c773d5ee15e8d99a2c237aa9846b110b2533df45ebf407d0e3ec6ed3dd03",
|
|
9
9
|
"conventions/atdd-bun.planner/atdd-bun.planner.identity-required.convention.yaml": "e96d7c1455d0c221072d82e7f2b55da1c4f9ceb17718eb6ed96affdeef37675b",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"conventions/coder.bun/coder.bun.interlocking-does-not-carry-cargo.convention.yaml": "c8f906c4aa6709aab7dc91e719331fd1865c229708acd9838373712a777138b1",
|
|
61
61
|
"conventions/coder.bun/coder.bun.interlocking-resolution-model-exists.convention.yaml": "dccd0fa99686811d2befc32861f995635b3e194004865710d5b9064a3feea8a8",
|
|
62
62
|
"conventions/coder.bun/coder.bun.interlocking-runner-exists.convention.yaml": "f7fadc580d2896f7d8341f555e4447480809329f3901209d30c2e54181032f15",
|
|
63
|
-
"conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml": "
|
|
63
|
+
"conventions/coder.bun/coder.bun.journey-runner-boundary.convention.yaml": "51df6caa810d887a84fc8196bc103465685400f12ba8a0504efef9b88c10fe33",
|
|
64
64
|
"conventions/coder.bun/coder.bun.layer-naming.convention.yaml": "009c21f6adbbc1e08d9781d7466b06e3d7fb7d935bf45b1df07fe37639a60a82",
|
|
65
65
|
"conventions/coder.bun/coder.bun.logging-console.convention.yaml": "b0e5765b8efba245cdd133f8321ba2364acbfae26a302f09e25cc905d652ae28",
|
|
66
66
|
"conventions/coder.bun/coder.bun.logging-structured.convention.yaml": "a7c8bd90762bf1516d2af63d4fe731720bec9f58431009f69af1bfebc0398759",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"conventions/coder.bun/coder.bun.security-missing-auth.convention.yaml": "2b937475e7a1f3f2718be6a3f0d94fa261ca65e72bbf8e3f2ca39ba143b90544",
|
|
78
78
|
"conventions/coder.bun/coder.bun.security-sql-injection.convention.yaml": "b4cbc3a446bbbf874762fd13c873305eaf7417c45a6206133163509b97267faa",
|
|
79
79
|
"conventions/coder.bun/coder.bun.station-master-interlocking-routing.convention.yaml": "b0b0572a79b4dd6f35db350980652afbdd797646d613c552b7c5595f72efdee8",
|
|
80
|
-
"conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml": "
|
|
80
|
+
"conventions/coder.bun/coder.bun.station-master-journey-routing.convention.yaml": "f03cb85e1a86d4811aa0a0e7e211900e482d16d168d5fd05d128a3e5cda4648e",
|
|
81
81
|
"conventions/coder.bun/coder.bun.telemetry-forbidden-properties.convention.yaml": "7dd411af2180e70a84f4bad99c568060504bb5ff5e7ccb9fc1b6fd4b3aadcbcd",
|
|
82
82
|
"conventions/coder.bun/coder.bun.telemetry-implementation-binding.convention.yaml": "5eb854948cdb8a1510fe808e3e5be13840d02b6bf07b4aa6d6ca94739128e7a7",
|
|
83
83
|
"conventions/coder.bun/coder.bun.telemetry-raw-string-emit.convention.yaml": "7fb7c83a916078184862c89edf1bf3bc6332ffd6bc8e2f17d96a221af1a40007",
|
|
@@ -161,7 +161,7 @@
|
|
|
161
161
|
"conventions/traceability/traceability.source.tested-by-resolves.convention.yaml": "ad4516670934dc82f1ebf9dc057110313059110302f88b4747dde89dd060870b",
|
|
162
162
|
"conventions/traceability/traceability.test.binding-resolves.convention.yaml": "03c118f498b1e824d457d25c060c8f9689ccbcca49c58d8bcd158e3cd3144ee9",
|
|
163
163
|
"detectors/atdd_topology/atdd.implementation.yaml": "7879f441a62691bf041b0fd8220feb12996d4a036d74d6c79d0985ee20992efb",
|
|
164
|
-
"detectors/atdd_topology/detect.mjs": "
|
|
164
|
+
"detectors/atdd_topology/detect.mjs": "ae03378b2354c794fc865ff66436abbdf11aacff29cef9284e4f90648dcbc6f9",
|
|
165
165
|
"detectors/atdd_topology/fixtures/clean/atdd-bun.yaml": "85049c05fb624037e1e2200a117b07d6c8d089d4e81fd784ade15a860a444f59",
|
|
166
166
|
"detectors/atdd_topology/fixtures/clean/e2e/interlockings/checkout/nominal.routes.test.ts": "be769e192e8165d4d36be743e8df578c1790b900c6b89acc7d2046ba40105135",
|
|
167
167
|
"detectors/atdd_topology/fixtures/clean/e2e/journeys/checkout.journey.test.ts": "345c391888e3e8c93d0437428dc1a3358968c7871ef54d313d6e083717254bbe",
|
|
@@ -431,8 +431,8 @@
|
|
|
431
431
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_resolution_model.mjs": "e761495b19eed0af09fcb4db34126d0e2398e91f086e04f5ef84d0b5c1c3ec16",
|
|
432
432
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_runner_exists.mjs": "6b63d3f26a51f2dd27847edd27c4d9c9f15c1f02747dba611cebccfc76f13169",
|
|
433
433
|
"detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs": "0221f5a8b49763388affefff670bc2a452e539e7658b08f6230a52f13edd36bd",
|
|
434
|
-
"detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "
|
|
435
|
-
"detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "
|
|
434
|
+
"detectors/bun_interlocking_infrastructure/checks/journey_runner_boundary.mjs": "570e4aef423881aed982b8f25a8d553f939d1ab82271236525c14efe3a20cd4a",
|
|
435
|
+
"detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs": "bfd4899a604c34de621475ce155e527b9281d1174c6daa13a2abc7bebabf2cde",
|
|
436
436
|
"detectors/bun_interlocking_infrastructure/checks/wagon_contract_honoured.mjs": "728f710827295472dadec182fcdf6c3e8fc44cdedeae53a669754be8435fdd7f",
|
|
437
437
|
"detectors/bun_interlocking_infrastructure/detect.mjs": "54c02f428596e057f828def0d68360b220b55dbf962b46694a21bc423ce57218",
|
|
438
438
|
"detectors/bun_interlocking_infrastructure/fixtures/clean/interlocking_delegates/server.ts": "36913357fdd3c5e375829a0554c605d70f5c05e2e83349f47505741598f6eb15",
|
|
@@ -1192,8 +1192,8 @@
|
|
|
1192
1192
|
"src/cli.ts": "bb2047cee60f48bfe3eaee4c1b3dcd3538a0e1edc170cf58e87686117bea07a7",
|
|
1193
1193
|
"src/delivery.ts": "8c2f964a4acc4471b6541f980c9c6f270b33cd64c95f5fe852a401060d507975",
|
|
1194
1194
|
"src/docs-capability.ts": "115cf27049a5cf19c133bac5ec237072be6f969a59a2ef6767e054108a21dc5c",
|
|
1195
|
-
"src/enforce.ts": "
|
|
1196
|
-
"src/hooks.ts": "
|
|
1195
|
+
"src/enforce.ts": "41a6f8c058a2a034996c817f224191403cc66ccc4ed4e04bbefc430014c783d8",
|
|
1196
|
+
"src/hooks.ts": "6766342c14b6f5a8f937433e15f04230436e3025a8316606e630481c96d9f35a",
|
|
1197
1197
|
"src/index.ts": "8ee4d7716794f6990ae8580e471b98256a0d0223dad063cd3b4542e598b6e744",
|
|
1198
1198
|
"src/integrity.ts": "ac516b1d3a73f872d0ed2b20a27ef0ac14ecc015293bd8e51954f322f8a063bb",
|
|
1199
1199
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
package/package.json
CHANGED
package/src/enforce.ts
CHANGED
|
@@ -101,7 +101,7 @@ export async function undeclaredEmissions(implementation: string, violations: Pi
|
|
|
101
101
|
|
|
102
102
|
export async function runImplementation(
|
|
103
103
|
implementation: string,
|
|
104
|
-
config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes"
|
|
104
|
+
config: Required<Pick<EnforcementConfig, "scanRoots" | "excludes">> & { profiles?: ConcreteProfile[] },
|
|
105
105
|
): Promise<Violation[]> {
|
|
106
106
|
const detector = join(detectorRoot, implementation, "detect.mjs");
|
|
107
107
|
const scratch = await mkdtemp(join(tmpdir(), "atdd-bun-"));
|
|
@@ -119,6 +119,9 @@ export async function runImplementation(
|
|
|
119
119
|
ATDD_SCAN_EXCLUDES: JSON.stringify(config.excludes),
|
|
120
120
|
ATDD_PLAN_ROOT: topology.planRoot,
|
|
121
121
|
ATDD_VIOLATIONS_REPORT: report,
|
|
122
|
+
// The profiles this run selected, so a detector shared by several profiles can tell a planning run from
|
|
123
|
+
// one that has reached RED or GREEN. Absent on a direct call, where detectors keep their full behaviour.
|
|
124
|
+
...(config.profiles ? { ATDD_PROFILES: JSON.stringify(config.profiles) } : {}),
|
|
122
125
|
},
|
|
123
126
|
});
|
|
124
127
|
const exitCode = await child.exited;
|
|
@@ -147,7 +150,7 @@ export async function enforce(config: EnforcementConfig = {}): Promise<Violation
|
|
|
147
150
|
const requested = config.profiles ?? ["all"], enabled = await enabledProfiles(root);
|
|
148
151
|
const selected = requested.includes("all") ? [...new Set([...requested.filter(p => p !== "all"), ...enabled])] : requested;
|
|
149
152
|
const results = await Promise.all(
|
|
150
|
-
implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes })),
|
|
153
|
+
implementationsFor(selected).map((implementation) => runImplementation(implementation, { scanRoots, excludes, profiles: selected as ConcreteProfile[] })),
|
|
151
154
|
);
|
|
152
155
|
return results.flat().sort((left, right) =>
|
|
153
156
|
left.rule_id.localeCompare(right.rule_id) || left.file.localeCompare(right.file) || left.line - right.line,
|
package/src/hooks.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { enabledProfiles, enforce } from "./enforce";
|
|
5
5
|
import { topologyFor } from "./topology";
|
|
6
|
+
import { JOURNEY_DOCS_DIR } from "./journey-docs";
|
|
6
7
|
|
|
7
8
|
export type WorktreePolicy = { enabled: boolean; root: string; primary_directory: string; primary_branch: string; require_linked_worktree: boolean };
|
|
8
9
|
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 };
|
|
@@ -16,10 +17,18 @@ const files = async (root: string, args: string[]) => (await git(root, args)).ou
|
|
|
16
17
|
const strings = (value: unknown) => Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined;
|
|
17
18
|
// Declarative registries are exempt from the micro-commit size caps but never from validation or removal approval.
|
|
18
19
|
const isRegistry = (cfg: HookPolicy, path: string) => cfg.registry_paths.some(pattern => new Bun.Glob(pattern).match(path));
|
|
20
|
+
/** The path a rename row of `git diff --numstat -M` lands on: `a => b`, or `dir/{a => b}/file`. */
|
|
21
|
+
const renamedTo = (path: string) => { const braced = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/); return braced ? `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/{2,}/g, "/") : path.includes(" => ") ? path.split(" => ")[1]! : path; };
|
|
22
|
+
/** Lines a commit changes, for the size cap: a moved file counts only the edits it carries, not its whole body twice. */
|
|
23
|
+
const stagedLineStats = async (root: string) => (await git(root, ["diff", "--cached", "--numstat", "-M"])).out.split("\n").filter(Boolean).map(row => { const [added, removed, ...path] = row.split("\t"); return { added: Number(added) || 0, removed: Number(removed) || 0, path: renamedTo(path.join("\t")) }; });
|
|
24
|
+
/** The journey view atdd-bun generates. Like a registry it is exempt from the size caps, since its index alone can
|
|
25
|
+
* exceed them and it cannot be split; unlike hand-written docs it is never exempt from the docs profile, which refuses
|
|
26
|
+
* a copy that is not byte-for-byte what the plan generates. */
|
|
27
|
+
const isGeneratedView = (path: string) => path.startsWith(`${JOURNEY_DOCS_DIR}/`);
|
|
19
28
|
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") }; });
|
|
20
29
|
|
|
21
30
|
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])) } }; }
|
|
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 enabled = await enabledProfiles(root), selected = full ? ["all" as const] : [...new Set(profiles)].filter(profile => enabled.includes(profile)); if (!selected.length) return { ok: true, message: "no activated profile covers the affected area" }; const findings = await enforce({ root, profiles: selected }); 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)}`); } }
|
|
31
|
+
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 (changed.some(isGeneratedView)) profiles.push("docs"); if (!profiles.length) return { ok: true, message: "no affected area" }; try { const enabled = await enabledProfiles(root), selected = full ? ["all" as const] : [...new Set(profiles)].filter(profile => enabled.includes(profile)); if (!selected.length) return { ok: true, message: "no activated profile covers the affected area" }; const findings = await enforce({ root, profiles: selected }); 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)}`); } }
|
|
23
32
|
|
|
24
33
|
const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
|
|
25
34
|
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)); }
|
|
@@ -31,7 +40,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
|
|
|
31
40
|
const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
|
|
32
41
|
if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
|
|
33
42
|
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
|
|
34
|
-
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = [...new Set([...await files(root, ["diff", "--name-only"]), ...await files(root, ["ls-files", "--others", "--exclude-standard"])])], registries = staged.filter(path => isRegistry(cfg, path)), counted = staged.length - registries.length, lines = (await
|
|
43
|
+
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), dirty = [...new Set([...await files(root, ["diff", "--name-only"]), ...await files(root, ["ls-files", "--others", "--exclude-standard"])])], registries = staged.filter(path => isRegistry(cfg, path)), generated = staged.filter(isGeneratedView), counted = staged.length - registries.length - generated.length, lines = (await stagedLineStats(root)).filter(row => !isRegistry(cfg, row.path) && !isGeneratedView(row.path)).reduce((n, row) => n + row.added + row.removed, 0); if (counted > cfg.max_staged_files) return bad(`staged files ${counted} exceed ${cfg.max_staged_files}`); if (dirty.length > cfg.max_uncommitted_files) return bad(`unstaged or untracked files ${dirty.length} exceed ${cfg.max_uncommitted_files}; the staged commit itself is not counted`); if (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
|
|
35
44
|
if (event === "commit-msg") { const deleted = (await files(root, ["diff", "--cached", "--name-only", "--diff-filter=D"])).length, stats = await stagedStats(root), lines = stats.reduce((n, row) => n + row.removed, 0), registryRemoved = stats.filter(row => isRegistry(cfg, row.path)).reduce((n, row) => n + row.removed - row.added, 0), message = args[0] && existsSync(args[0]) ? await readFile(args[0], "utf8") : "", approved = message.includes("[mass-delete-approved]"); if ((deleted > 50 || lines > 10_000) && !approved) return bad("mass delete requires [mass-delete-approved]"); return registryRemoved > cfg.max_registry_removed_lines && !approved ? bad(`registry removal of ${registryRemoved} net lines exceeds ${cfg.max_registry_removed_lines}; requires [mass-delete-approved]`) : { ok: true, message: "ok" }; }
|
|
36
45
|
if (event === "pre-push") { for (const row of stdin.split("\n").filter(Boolean).map(row => row.split(/\s+/))) { const [,,remote, remoteSha] = row, target = remote?.replace("refs/heads/", ""); if (target && cfg.protected_branches.includes(target)) return bad(`protected branch ${target} is blocked`); const local = row[1]; if (local && !/^0+$/.test(local)) { const range = !remoteSha || /^0+$/.test(remoteSha) ? `${local}^..${local}` : `${remoteSha}..${local}`, count = Number((await git(root, ["rev-list", "--count", range])).out); if (count > cfg.max_commits_per_push) return bad(`commits per push ${count} exceed ${cfg.max_commits_per_push}`); } } return validation(root, await files(root, ["diff", "--name-only", "HEAD~1..HEAD"]), true); }
|
|
37
46
|
if (event === "post-commit") { const result = await validation(root, await files(root, ["show", "--pretty=format:", "--name-only", "HEAD"]), false); return { ok: true, message: result.ok ? result.message : `advisory: ${result.message}` }; }
|