@afokapu/atdd-bun 0.10.49 → 0.10.51

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 CHANGED
@@ -61,6 +61,7 @@ registerEnforcementTest({ root: import.meta.dir + "/..", profiles: ["traceabilit
61
61
  | `interlocking` | train/interlocking binding, infrastructure and route coverage |
62
62
  | `htmx` | htmx source/test conventions and Playwright browser specs (`*.e2e.ts`) |
63
63
  | `design` | design-system layering, token-only styling and responsiveness |
64
+ | `flow` | optional ATDD Flow companion conventions for durable seats, tasks and threads |
64
65
  | `all` | every activated profile; what the hooks and CI run |
65
66
 
66
67
  `planner-nodes/ENFORCEMENT_SCOPE.yaml` says which canonical planner rules have a Bun realization.
@@ -80,6 +81,16 @@ profiles: [traceability, planner]
80
81
  by name (`bun run atdd-bun coder`) to see what remains. An unknown name or an empty list is an
81
82
  error, never a silent run of nothing.
82
83
 
84
+ ### Flow profile compatibility
85
+
86
+ `flow` is the canonical optional profile name. For a compatible upgrade, a legacy explicit
87
+ `workflow` entry in `atdd-bun.yaml`, or a direct `bun run atdd-bun workflow`, normalizes to
88
+ `flow`; it does not make unrelated names valid. `flow` requires `@afokapu/atdd-bun` **0.10.45 or
89
+ newer**. If a checkout reports that `flow` is unknown, it is running a stale toolkit: run
90
+ `bun update @afokapu/atdd-bun`, then use the repository-local command
91
+ `bun run atdd-bun flow --root .` (never a PATH-global `atdd-bun`). Keep `flow` in newly edited
92
+ configuration.
93
+
83
94
  With no `profiles:` field, every profile runs, but none is governed yet. The first explicit list is
84
95
  the adoption that establishes the governed set, so a brownfield repository can declare
85
96
  `profiles: [docs]` in an ordinary pull request. From then on, the integrity check reports, against
@@ -111,6 +122,27 @@ release: { enabled: false }
111
122
 
112
123
  When enabled without overrides, a primary checkout at `~/Github/<repo>` uses linked worktrees at `~/Github/worktrees/<repo>/<branch>`. The defaults use `primary_directory: .` (the current primary checkout) and `root: ../worktrees/{repo}`; `{repo}` expands to the primary checkout's directory name. Repositories that need another layout can set either field explicitly.
113
124
 
125
+ ## Exact-base finding ratchet (opt-in)
126
+
127
+ Strict enforcement is the default: `bun run atdd-bun all` still fails for every finding. A consumer may opt into an exact-base comparison for one explicit profile set:
128
+
129
+ ```yaml
130
+ # atdd-bun.yaml
131
+ ratchet:
132
+ mode: report # or reject-new
133
+ profiles: [coder, tester]
134
+ ```
135
+
136
+ Run it only against a full reviewed base SHA:
137
+
138
+ ```sh
139
+ bun run atdd-bun coder tester --ratchet --base <40-character-reviewed-main-sha>
140
+ ```
141
+
142
+ The command runs the same installed detectors against an isolated exact base and the clean candidate, emits a redacted JSON `carried`/`new`/`resolved` fingerprint report, and never writes or accepts a baseline. `report` records debt without changing command success; `reject-new` exits non-zero only when the candidate adds a finding absent from the equivalent base. The declared `ratchet.profiles` must exactly match the command's effective profiles. Base and candidate profile/context digests must match or comparison fails closed; profile/config adoption is separately governed. Use debt-remediation branches to remove carried findings. Direct implementation profiles can opt in only through this explicit policy and do not alter configured merge profiles or `profiles:`.
143
+
144
+ Fingerprints are SHA-256 of a versioned rule, normalized repository-relative path, and semantic subject where supplied; line/column, rendered evidence, source text, absolute paths, and runtime data are excluded. Ratchet findings are unrelated to Bun `test.failing()` or JUnit/behavioral-test baselines.
145
+
114
146
  ## Docs-site theme
115
147
 
116
148
  A repository that publishes its AsciiDoc docs as a site does not need its own theme.
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.10.49",
2
+ "version": "0.10.51",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "5b46af2c4be56303b3ef4b136e443c55d008f29705850265492328c196a3d7e2",
6
+ "README.md": "16cac8c84aa9b11018b4b1cc2984faa74c06bee1d7ce1360fb234154bbce2d9f",
7
7
  "bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
8
8
  "conventions/_profiles/architecture.yaml": "f1808768b720a4e476047e4a0912002ca673946562cea9aeef5d0c214828d93a",
9
9
  "conventions/_profiles/coder.yaml": "3fb191556a64c9ebcf249516afa189afca24da9b22fd40b2fc66908196cdfb78",
@@ -1195,9 +1195,9 @@
1195
1195
  "relationships.yaml": "5860a308ebc3ee7fa8172fc2c6633984000ab3003f0ae4b6fa28db48879eda22",
1196
1196
  "src/agent.ts": "39ca8b3183063c60e00eb96e5629d0397aeb83cc82d4c4bc32c01715c906649b",
1197
1197
  "src/ci.ts": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
1198
- "src/cli.ts": "cbc50f1e7fc7d0935f78230b7da6e232ed7ee6f04828e015c599fb6f6edcbfa2",
1198
+ "src/cli.ts": "8f5d69bdd5940c7213f8b7c342fd94c1dc9387d56dad7458ce6e9286d88181d3",
1199
1199
  "src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
1200
- "src/enforce.ts": "4738f4be630e9087415a12f8e0cbbf63ced02da027c665cf0dc5a514d4dc6d94",
1200
+ "src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
1201
1201
  "src/hooks.ts": "0b4ca8c857e637bbd144e737e78eda924e3cba7b038c0abe5e6916d200d73068",
1202
1202
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1203
1203
  "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
@@ -1206,6 +1206,7 @@
1206
1206
  "src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
1207
1207
  "src/planner-validators.ts": "99adeb65f579af923c11f21a8f55cfb9993b007fd8df59c5f764696b55379cfc",
1208
1208
  "src/profile-registry.ts": "5a7761d324181ae27deb51f198d63648a4c99e858b4de98b54d35e6cf8a39fe7",
1209
+ "src/ratchet.ts": "c4ebf34350bc3909db93754f2c92de065325763859bd3040dfaf3d6b204ef33e",
1209
1210
  "src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
1210
1211
  "src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
1211
1212
  "src/setup.ts": "38233265afedc63499bbef3b5403889651add335a70efd3813254ba53f4fc4fe",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.49",
3
+ "version": "0.10.51",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
package/src/cli.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { enforce, profileNames, type Profile } from "./enforce";
2
+ import { enforce, enabledProfiles, flowCompatibilityDiagnostic, normalizeProfileName, profileNames, type Profile } from "./enforce";
3
+ import { compareFindings, contextDigest, parseRatchetPolicy } from "./ratchet";
4
+ import { existsSync } from "node:fs";
5
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join, relative, resolve } from "node:path";
3
8
  import { finishWorktree, hookEvents, hooksStatus, installHooks, runHook, startWorktree, uninstallHooks, worktreeStatus } from "./hooks";
4
9
  import { ciInit, ciStatus } from "./ci";
5
10
  import { agentInit, agentStatus } from "./agent";
@@ -14,7 +19,7 @@ const args = process.argv.slice(2);
14
19
  const usage = {
15
20
  command: "atdd-bun",
16
21
  usage: [
17
- "atdd-bun [profile ...] [--root <path>]",
22
+ "atdd-bun [profile ...] [--root <path>] [--ratchet --base <full-sha>]",
18
23
  "atdd-bun init [--replace]",
19
24
  "atdd-bun hooks <install|uninstall|status> [--replace]",
20
25
  "atdd-bun worktree <start|finish|status>",
@@ -109,20 +114,69 @@ if (args[0] === "release") {
109
114
  const valueAfter = (flag: string) => args[args.indexOf(flag) + 1];
110
115
  const root = args.includes("--root") ? valueAfter("--root") : process.cwd();
111
116
  if (args.includes("--root") && !root) fail("--root requires a path");
117
+ if (args.includes("--base") && !valueAfter("--base")) fail("--base requires a full commit SHA");
112
118
  const positional: string[] = [];
113
119
  for (let index = 0; index < args.length; index += 1) {
114
120
  const arg = args[index];
115
- if (arg === "--root" || arg === "--profile") { index += 1; continue; }
121
+ if (arg === "--root" || arg === "--profile" || arg === "--base") { index += 1; continue; }
116
122
  if (!arg.startsWith("-")) positional.push(arg);
117
123
  }
118
124
  const profileValue = args.includes("--profile") ? valueAfter("--profile") : undefined;
119
125
  if (args.includes("--profile") && !profileValue) fail("--profile requires one or more comma-separated profiles");
120
126
  const requested = profileValue ? profileValue.split(",").filter(Boolean) : positional.length ? positional : ["all"];
121
- const invalid = requested.find(profile => !profileNames.includes(profile as Profile));
127
+ const normalizedRequested = requested.map(normalizeProfileName);
128
+ const invalid = normalizedRequested.find(profile => !profileNames.includes(profile as Profile));
122
129
  if (invalid) fail("unknown command or profile: " + invalid);
123
- const violations = await enforce({ root, profiles: requested as Profile[] });
124
- for (const violation of violations) {
125
- console.error([violation.file, violation.line, violation.col].join(":") + " " + violation.rule_id + " — " + violation.evidence);
130
+ if (requested.includes("workflow")) console.error(flowCompatibilityDiagnostic);
131
+ const printViolations = (violations: Awaited<ReturnType<typeof enforce>>) => {
132
+ for (const violation of violations) console.error([violation.file, violation.line, violation.col].join(":") + " " + violation.rule_id + " — " + violation.evidence);
133
+ };
134
+ const configAt = async (directory: string) => {
135
+ const file = join(directory, "atdd-bun.yaml");
136
+ return existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> : {};
137
+ };
138
+ const git = async (directory: string, gitArgs: string[]) => {
139
+ const child = Bun.spawn({ cmd: ["git", ...gitArgs], cwd: directory, stdout: "pipe", stderr: "pipe" });
140
+ return { code: await child.exited, out: (await new Response(child.stdout).text()).trim(), err: (await new Response(child.stderr).text()).trim() };
141
+ };
142
+ const stable = (items: string[]) => [...new Set(items)].sort();
143
+ if (!args.includes("--ratchet")) {
144
+ const violations = await enforce({ root, profiles: normalizedRequested as Profile[] });
145
+ printViolations(violations);
146
+ if (violations.length) console.error("\nUse the rule ID and evidence above to correct the affected artifact. Run atdd-bun help for commands and profiles.");
147
+ process.exitCode = violations.length ? 1 : 0;
148
+ } else {
149
+ const base = valueAfter("--base");
150
+ if (!base || !/^[0-9a-f]{40}$/i.test(base)) fail("--ratchet requires --base <full-40-character-sha>");
151
+ const candidateRoot = resolve(root), candidateConfig = await configAt(candidateRoot), policy = parseRatchetPolicy(candidateConfig);
152
+ if (!policy) fail("--ratchet requires an explicit atdd-bun.yaml ratchet policy");
153
+ const candidateProfiles = normalizedRequested.includes("all") ? await enabledProfiles(candidateRoot) : normalizedRequested;
154
+ if (stable(policy.profiles).join(",") !== stable(candidateProfiles).join(",")) fail("ratchet.profiles must exactly equal the selected profiles");
155
+ if ((await git(candidateRoot, ["status", "--porcelain"])).out) fail("--ratchet requires a porcelain-clean candidate worktree");
156
+ const resolved = await git(candidateRoot, ["rev-parse", "--verify", `${base}^{commit}`]);
157
+ if (resolved.code || resolved.out.toLowerCase() !== base.toLowerCase()) fail(`ratchet base ${base} is unavailable or not a full exact commit`);
158
+ if ((await git(candidateRoot, ["merge-base", "--is-ancestor", base, "HEAD"])).code !== 0) fail("ratchet base must be an ancestor of candidate HEAD");
159
+ const scratch = await mkdtemp(join(tmpdir(), "atdd-ratchet-"));
160
+ try {
161
+ const added = await git(candidateRoot, ["worktree", "add", "--detach", "--no-checkout", scratch, base]);
162
+ if (added.code) { console.error(`could not materialize ratchet base: ${added.err || added.out}`); process.exitCode = 1; }
163
+ else {
164
+ const checkedOut = await git(scratch, ["checkout", "--detach", base]);
165
+ if (checkedOut.code) { console.error(`could not checkout ratchet base: ${checkedOut.err || checkedOut.out}`); process.exitCode = 1; }
166
+ else {
167
+ const baseConfig = await configAt(scratch), baseProfiles = normalizedRequested.includes("all") ? await enabledProfiles(scratch) : normalizedRequested;
168
+ if (stable(baseProfiles).join(",") !== stable(candidateProfiles).join(",") || contextDigest(baseConfig, baseProfiles) !== contextDigest(candidateConfig, candidateProfiles)) { console.error("ratchet base and candidate profile/context differ; use a separately governed adoption path"); process.exitCode = 1; }
169
+ else {
170
+ const [baseFindings, candidateFindings] = await Promise.all([enforce({ root: scratch, profiles: candidateProfiles as Profile[] }), enforce({ root: candidateRoot, profiles: candidateProfiles as Profile[] })]);
171
+ const normalize = (findings: Awaited<ReturnType<typeof enforce>>, directory: string) => findings.map(finding => ({ ...finding, file: relative(directory, finding.file).replaceAll("\\", "/") }));
172
+ const delta = compareFindings(normalize(baseFindings, scratch), normalize(candidateFindings, candidateRoot));
173
+ console.log(JSON.stringify({ schema: "atdd-bun.ratchet-report/v1", mode: policy.mode, base, candidate: (await git(candidateRoot, ["rev-parse", "HEAD"])).out, profiles: stable(candidateProfiles), context: contextDigest(candidateConfig, candidateProfiles), ...delta }, null, 2));
174
+ if (policy.mode === "reject-new" && delta.new.length) process.exitCode = 1;
175
+ }
176
+ }
177
+ }
178
+ } finally {
179
+ await git(candidateRoot, ["worktree", "remove", "--force", scratch]);
180
+ await rm(scratch, { recursive: true, force: true });
181
+ }
126
182
  }
127
- if (violations.length) console.error("\nUse the rule ID and evidence above to correct the affected artifact. Run atdd-bun help for commands and profiles.");
128
- process.exitCode = violations.length ? 1 : 0;
package/src/enforce.ts CHANGED
@@ -60,6 +60,16 @@ export const optionalProfiles: ConcreteProfile[] = ["flow"];
60
60
  export const defaultProfiles = concreteProfiles.filter(profile => !optionalProfiles.includes(profile));
61
61
  /** Profile names accepted by the CLI and public integrations. */
62
62
  export const profileNames = [...concreteProfiles, "all"] as Profile[];
63
+ /** Retired spellings accepted only to migrate old configuration and invocations to their canonical profile. */
64
+ export const legacyProfileAliases = { workflow: "flow" } as const;
65
+
66
+ /** Normalize a supported retired spelling without making it part of the canonical profile registry. */
67
+ export function normalizeProfileName(name: string): string {
68
+ return legacyProfileAliases[name as keyof typeof legacyProfileAliases] ?? name;
69
+ }
70
+
71
+ /** Guidance for a repository that was configured during the workflow → flow rename. */
72
+ export const flowCompatibilityDiagnostic = "Legacy profile workflow is normalized to canonical flow. If this checkout reports flow as unknown, it is using @afokapu/atdd-bun older than 0.10.45; run bun update @afokapu/atdd-bun, then invoke the project-local CLI with bun run atdd-bun flow --root . (not a PATH-global atdd-bun).";
63
73
 
64
74
  /**
65
75
  * The profiles the operator has activated: `profiles:` in atdd-bun.yaml, or every built-in profile when absent. A legacy
@@ -72,9 +82,10 @@ export async function enabledProfiles(root = process.cwd()): Promise<ConcretePro
72
82
  const listed = existsSync(file) ? (Bun.YAML.parse(await readFile(file, "utf8")) as { profiles?: unknown } | null)?.profiles : undefined;
73
83
  if (listed === undefined) return defaultProfiles;
74
84
  if (!Array.isArray(listed) || !listed.length) throw new Error("atdd-bun.yaml profiles must be a non-empty list of profile names");
75
- const unknown = listed.filter(name => !concreteProfiles.includes(name));
85
+ const normalized = listed.map(name => typeof name === "string" ? normalizeProfileName(name) : name);
86
+ const unknown = normalized.filter(name => typeof name !== "string" || !concreteProfiles.includes(name as ConcreteProfile));
76
87
  if (unknown.length) throw new Error(`atdd-bun.yaml profiles lists unknown profile(s): ${unknown.join(", ")}; known: ${concreteProfiles.join(", ")}`);
77
- return [...new Set(listed as ConcreteProfile[])];
88
+ return [...new Set(normalized as ConcreteProfile[])];
78
89
  }
79
90
 
80
91
  const packageRoot = resolve(import.meta.dir, "..");
package/src/ratchet.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Pure v1 primitives for the opt-in exact-base finding ratchet. No baseline is written or accepted. */
2
+ export type RatchetMode = "report" | "reject-new";
3
+ export type RatchetPolicy = { mode: RatchetMode; profiles: string[] };
4
+ export type FindingIdentity = {
5
+ rule_id: string;
6
+ file: string;
7
+ subject?: string;
8
+ line?: number;
9
+ col?: number;
10
+ evidence?: string;
11
+ source_line?: string;
12
+ };
13
+
14
+ const canonical = (value: unknown): string => {
15
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") return JSON.stringify(value);
16
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
17
+ if (typeof value === "object") return `{${Object.entries(value as Record<string, unknown>).filter(([, entry]) => entry !== undefined).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(",")}}`;
18
+ throw new Error(`cannot canonicalize ${typeof value}`);
19
+ };
20
+ const sha256 = (value: unknown) => new Bun.CryptoHasher("sha256").update(canonical(value)).digest("hex");
21
+ const normalizedPath = (path: string) => {
22
+ const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "");
23
+ if (!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) || normalized.split("/").includes("..")) throw new Error(`ratchet finding path must be normalized and repository-relative: ${path}`);
24
+ return normalized;
25
+ };
26
+
27
+ /** Stable semantic identity: presentation position and rendered evidence deliberately do not participate. */
28
+ export function findingFingerprint(finding: FindingIdentity): string {
29
+ const path = normalizedPath(finding.file), subject = finding.subject ?? path;
30
+ if (!finding.rule_id || !subject) throw new Error("ratchet finding requires rule_id and semantic subject");
31
+ return sha256({ format: "atdd-bun.finding-fingerprint/v1", rule_id: finding.rule_id, path, subject });
32
+ }
33
+
34
+ /** The selected profiles and every policy key except ratchet itself must agree across exact base and candidate. */
35
+ export function contextDigest(config: Record<string, unknown>, profiles: string[]): string {
36
+ const { ratchet: _ratchet, ...policy } = config;
37
+ return sha256({ format: "atdd-bun.ratchet-context/v1", profiles: [...profiles].sort(), policy });
38
+ }
39
+
40
+ export function parseRatchetPolicy(config: Record<string, unknown>): RatchetPolicy | null {
41
+ if (config.ratchet === undefined) return null;
42
+ const value = config.ratchet;
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("atdd-bun.yaml ratchet must be a mapping");
44
+ const mode = (value as Record<string, unknown>).mode, profiles = (value as Record<string, unknown>).profiles;
45
+ if (mode !== "report" && mode !== "reject-new") throw new Error("atdd-bun.yaml ratchet.mode must be report or reject-new");
46
+ 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");
47
+ return { mode, profiles: [...new Set(profiles)].sort() };
48
+ }
49
+
50
+ export function compareFindings(base: FindingIdentity[], candidate: FindingIdentity[]) {
51
+ const baseSet = new Set(base.map(findingFingerprint)), candidateSet = new Set(candidate.map(findingFingerprint));
52
+ const list = (source: Set<string>, absent: Set<string>) => [...source].filter(item => !absent.has(item)).sort();
53
+ return { carried: [...baseSet].filter(item => candidateSet.has(item)).sort(), new: list(candidateSet, baseSet), resolved: list(baseSet, candidateSet) };
54
+ }