@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 +32 -0
- package/integrity.json +5 -4
- package/package.json +1 -1
- package/src/cli.ts +63 -9
- package/src/enforce.ts +13 -2
- package/src/ratchet.ts +54 -0
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.
|
|
2
|
+
"version": "0.10.51",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
6
|
-
"README.md": "
|
|
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": "
|
|
1198
|
+
"src/cli.ts": "8f5d69bdd5940c7213f8b7c342fd94c1dc9387d56dad7458ce6e9286d88181d3",
|
|
1199
1199
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1200
|
-
"src/enforce.ts": "
|
|
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
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
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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(
|
|
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
|
+
}
|