@afokapu/atdd-bun 0.10.58 → 0.10.60
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 +4 -0
- package/detectors/bun_interlocking_coverage/_shared/interlocking.mjs +258 -36
- package/detectors/bun_interlocking_coverage/checks/interlocking_production_runner.mjs +6 -2
- package/detectors/bun_interlocking_coverage/checks/interlocking_route_coverage.mjs +10 -1
- package/detectors/bun_interlocking_coverage/checks/interlocking_smoke_coverage.mjs +11 -10
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/e2e/smoke/resolve-match.smoke.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/plan/_trains/_interlockings/match.yaml +9 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/server.ts +10 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/interlocking.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/journey.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_mediated_train_path_runner/src/trains/runner.ts +6 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/scenario-compilation/compile.routes.test.ts +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/session-replay/replay.routes.test.ts +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/interlockings/source-corpus-admission/refuse.routes.test.ts +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/smoke/runtime-station-master.test.ts +44 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/e2e/support/production-train.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/create-world-pack.yaml +18 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/play-world-scenario.yaml +24 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_journeys/replay-world-session.yaml +18 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/scenario-compilation.yaml +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/session-replay.yaml +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/plan/_trains/_interlockings/source-corpus-admission.yaml +14 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/server.ts +85 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/interlocking.ts +64 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/journey.ts +71 -0
- package/detectors/bun_interlocking_coverage/fixtures/clean/journey_station_master_production_shape/src/trains/runner.ts +28 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/e2e/smoke/resolve-match.smoke.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/plan/_trains/_interlockings/match.yaml +9 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/server.ts +21 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/interlocking.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/journey.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_mediated_direct_business/src/trains/runner.ts +6 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/e2e/smoke/resolve-match.smoke.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/plan/_trains/_interlockings/match.yaml +9 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/server.ts +11 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/interlocking.ts +12 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/journey.test.ts +8 -0
- package/detectors/bun_interlocking_coverage/fixtures/dirty/journey_test_local_lookalike/src/trains/runner.ts +6 -0
- package/detectors/bun_interlocking_infrastructure/checks/interlocking_station_routing.mjs +5 -0
- package/detectors/bun_interlocking_infrastructure/checks/journey_station_routing.mjs +5 -1
- package/integrity.json +44 -10
- package/lib/journey-chain.mjs +677 -0
- package/package.json +3 -2
- package/src/hooks.ts +52 -4
- package/src/ratchet.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@afokapu/atdd-bun",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.60",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/afokapu/atdd-bun.git"
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@afokapu/atdd-flow": "^0.3.0",
|
|
39
39
|
"ajv": "^8.17.1",
|
|
40
|
-
"ajv-formats": "^3.0.1"
|
|
40
|
+
"ajv-formats": "^3.0.1",
|
|
41
|
+
"typescript": "6.0.3"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@axe-core/playwright": "^4.13.0",
|
package/src/hooks.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { chmod, mkdir, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { concreteProfiles, enabledProfiles, enforce, normalizeProfileName } from "./enforce";
|
|
6
|
+
import { activationContextDigest, canonicalFindingPath, compareFindings, contextDigest, isStrictProfileExpansion, parseRatchetPolicy } from "./ratchet";
|
|
5
7
|
import { topologyFor } from "./topology";
|
|
6
8
|
import { JOURNEY_DOCS_DIR } from "./journey-docs";
|
|
7
9
|
|
|
@@ -11,7 +13,9 @@ export const defaultWorktreePolicy: WorktreePolicy = { enabled: false, root: "..
|
|
|
11
13
|
export const defaultHookPolicy: HookPolicy = { max_staged_files: 20, max_staged_changed_lines: 350, max_commits_per_push: 10, max_registry_removed_lines: 350, registry_paths: ["plan/_*.yaml", "plan/_*.yml", "contracts/_*.yaml", "contracts/_*.yml"], protected_branches: ["main", "master"], require_plan_reference: true, require_traceability: true, worktrees: defaultWorktreePolicy };
|
|
12
14
|
export const hookEvents = ["pre-commit", "commit-msg", "pre-push", "pre-merge-commit", "post-commit", "post-merge"] as const;
|
|
13
15
|
export type HookEvent = typeof hookEvents[number];
|
|
14
|
-
|
|
16
|
+
// Git exports these repository-local variables to hooks. A temporary worktree must discover its own Git directory instead.
|
|
17
|
+
const gitEnvironment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_QUARANTINE_PATH"].includes(key)));
|
|
18
|
+
const git = async (root: string, args: string[], input?: string, raw = false) => { const child = Bun.spawn({ cmd: ["git", ...args], cwd: root, env: gitEnvironment, stdin: input ? new Blob([input]) : undefined, stdout: "pipe", stderr: "pipe" }); const out = await new Response(child.stdout).text(), err = await new Response(child.stderr).text(); return { code: await child.exited, out: raw ? out : out.trim(), err: raw ? err : err.trim() }; };
|
|
15
19
|
const bad = (message: string) => ({ ok: false, message });
|
|
16
20
|
const files = async (root: string, args: string[]) => (await git(root, args)).out.split("\n").filter(Boolean);
|
|
17
21
|
const strings = (value: unknown) => Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : undefined;
|
|
@@ -30,6 +34,50 @@ const stagedStats = async (root: string) => (await git(root, ["diff", "--cached"
|
|
|
30
34
|
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])) } }; }
|
|
31
35
|
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)}`); } }
|
|
32
36
|
|
|
37
|
+
const stable = (profiles: string[]) => [...new Set(profiles)].sort();
|
|
38
|
+
const configAt = async (root: string) => {
|
|
39
|
+
const file = join(root, "atdd-bun.yaml");
|
|
40
|
+
return existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> : {};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The sole bootstrap exception: a staged strict profile expansion is compared with its exact HEAD, never accepted. */
|
|
44
|
+
async function profileActivationCommitGate(root: string): Promise<{ ok: boolean; message: string } | null> {
|
|
45
|
+
const stagedConfig = await git(root, ["show", ":atdd-bun.yaml"]);
|
|
46
|
+
if (stagedConfig.code) return null;
|
|
47
|
+
let candidateConfig: Record<string, unknown>, policy;
|
|
48
|
+
try { candidateConfig = Bun.YAML.parse(stagedConfig.out) as Record<string, unknown>; policy = parseRatchetPolicy(candidateConfig); }
|
|
49
|
+
catch (error) { return bad(`ratchet activation commit gate configuration is invalid: ${String(error)}`); }
|
|
50
|
+
if (policy?.mode !== "reject-new" || !Array.isArray(candidateConfig.profiles) || !candidateConfig.profiles.every(profile => typeof profile === "string")) return null;
|
|
51
|
+
if ((await git(root, ["diff", "--quiet"])).code !== 0 || (await git(root, ["ls-files", "--others", "--exclude-standard"])).out) return bad("ratchet activation commit gate requires no unstaged or untracked candidate state");
|
|
52
|
+
const head = await git(root, ["rev-parse", "--verify", "HEAD^{commit}"]);
|
|
53
|
+
if (head.code || !/^[0-9a-f]{40}$/i.test(head.out)) return bad("ratchet activation commit gate requires an exact committed HEAD base");
|
|
54
|
+
const scratch = await mkdtemp(join(tmpdir(), "atdd-ratchet-commit-gate-"));
|
|
55
|
+
try {
|
|
56
|
+
const added = await git(root, ["worktree", "add", "--detach", "--no-checkout", scratch, head.out]);
|
|
57
|
+
if (added.code) return bad(`could not materialize ratchet activation base: ${added.err || added.out}`);
|
|
58
|
+
const checkedOut = await git(scratch, ["checkout", "--detach", head.out]);
|
|
59
|
+
if (checkedOut.code) return bad(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
|
|
60
|
+
const baseConfig = await configAt(scratch), candidateProfiles = stable(candidateConfig.profiles.map(normalizeProfileName));
|
|
61
|
+
const baseProfiles = stable(await enabledProfiles(scratch));
|
|
62
|
+
if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || stable(policy.profiles.map(normalizeProfileName)).join(",") !== candidateProfiles.join(",") || !isStrictProfileExpansion(baseProfiles, candidateProfiles) || activationContextDigest(baseConfig) !== activationContextDigest(candidateConfig)) return bad("ratchet profile activation requires an explicit monotonic profile expansion and identical non-profile context");
|
|
63
|
+
const baseFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
|
|
64
|
+
const patch = await git(root, ["diff", "--cached", "--binary"], undefined, true);
|
|
65
|
+
if (patch.code) return bad(`could not read staged ratchet activation candidate: ${patch.err || patch.out}`);
|
|
66
|
+
const applied = await git(scratch, ["apply", "--whitespace=nowarn"], patch.out);
|
|
67
|
+
if (applied.code) return bad(`could not materialize staged ratchet activation candidate: ${applied.err || applied.out}`);
|
|
68
|
+
const materialized = await configAt(scratch);
|
|
69
|
+
if (activationContextDigest(materialized) !== activationContextDigest(candidateConfig)) return bad("staged ratchet activation candidate did not materialize exactly");
|
|
70
|
+
const candidateFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
|
|
71
|
+
const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
|
|
72
|
+
const delta = compareFindings(normalize(baseFindings), normalize(candidateFindings));
|
|
73
|
+
const tree = await git(root, ["write-tree"]);
|
|
74
|
+
if (tree.code || !/^[0-9a-f]{40}$/i.test(tree.out)) return bad(`could not identify staged ratchet activation candidate: ${tree.err || tree.out}`);
|
|
75
|
+
const report = JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base: head.out, candidate: tree.out, profiles: candidateProfiles, context: contextDigest(candidateConfig, candidateProfiles), ...delta });
|
|
76
|
+
return { ok: delta.new.length === 0, message: report };
|
|
77
|
+
} catch (error) { return bad(`ratchet activation commit gate failed closed: ${String(error)}`); }
|
|
78
|
+
finally { await git(root, ["worktree", "remove", "--force", scratch]); await rm(scratch, { recursive: true, force: true }); }
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
const inside = (parent: string, child: string) => { const path = relative(parent, child); return path === "" || (path !== ".." && !path.startsWith("../") && !path.startsWith("..\\")); };
|
|
34
82
|
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)); }
|
|
35
83
|
export type WorktreeLayout = { primary: string; worktreeRoot: string; current: string; branch: string };
|
|
@@ -40,7 +88,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
|
|
|
40
88
|
const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
|
|
41
89
|
if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
|
|
42
90
|
if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
|
|
43
|
-
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), 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 (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" }; }
|
|
91
|
+
if (event === "pre-commit") { const staged = await files(root, ["diff", "--cached", "--name-only"]), 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 (lines > cfg.max_staged_changed_lines) return bad(`staged changed lines ${lines} exceed ${cfg.max_staged_changed_lines}`); const activation = await profileActivationCommitGate(root); if (activation) return activation; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
|
|
44
92
|
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" }; }
|
|
45
93
|
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); }
|
|
46
94
|
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}` }; }
|
package/src/ratchet.ts
CHANGED
|
@@ -62,7 +62,8 @@ export function parseRatchetPolicy(config: Record<string, unknown>): RatchetPoli
|
|
|
62
62
|
if (config.ratchet === undefined) return null;
|
|
63
63
|
const value = config.ratchet;
|
|
64
64
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("atdd-bun.yaml ratchet must be a mapping");
|
|
65
|
-
const
|
|
65
|
+
const fields = value as Record<string, unknown>, mode = fields.mode, profiles = fields.profiles;
|
|
66
|
+
if (Object.keys(fields).some(key => key !== "mode" && key !== "profiles")) throw new Error("atdd-bun.yaml ratchet permits only mode and profiles; baselines and waivers are unsupported");
|
|
66
67
|
if (mode !== "report" && mode !== "reject-new") throw new Error("atdd-bun.yaml ratchet.mode must be report or reject-new");
|
|
67
68
|
if (!Array.isArray(profiles) || !profiles.length || !profiles.every(profile => typeof profile === "string")) throw new Error("atdd-bun.yaml ratchet.profiles must be a non-empty list of profile names");
|
|
68
69
|
return { mode, profiles: [...new Set(profiles)].sort() };
|