@afokapu/atdd-bun 0.10.59 → 0.10.61
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 +11 -0
- package/integrity.json +6 -6
- package/package.json +1 -1
- package/src/ci.ts +32 -1
- package/src/hooks.ts +52 -4
- package/src/ratchet.ts +2 -1
- package/templates/github/atdd-bun.yml +12 -1
package/README.md
CHANGED
|
@@ -25,6 +25,13 @@ without `--replace`, and adding the dependency changes nothing until you run `in
|
|
|
25
25
|
|
|
26
26
|
Then require the workflow's job in the GitHub branch ruleset, so it gates merges.
|
|
27
27
|
|
|
28
|
+
For a declared GitHub Packages scope in `.npmrc` (for example,
|
|
29
|
+
`@forgeonehundred:registry=https://npm.pkg.github.com`), run
|
|
30
|
+
`bun run atdd-bun ci init --replace`. The generator preserves that scope and adds
|
|
31
|
+
only `//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}`; the generated workflow
|
|
32
|
+
supplies the ephemeral Actions token to its frozen install step. It refuses an
|
|
33
|
+
existing stored token rather than reading, copying, or replacing it.
|
|
34
|
+
|
|
28
35
|
## Commands
|
|
29
36
|
|
|
30
37
|
| Command | What it does |
|
|
@@ -153,6 +160,10 @@ bun run atdd-bun --profile flow,traceability --ratchet --ratchet-activate-profil
|
|
|
153
160
|
|
|
154
161
|
This is an activation-only exception to the ordinary equal-context rule, not a baseline migration. It requires an explicit candidate `profiles:` list, an explicit base list that is a strict subset of it, and identical non-profile policy/configuration. A removal, implicit/default-list adoption, unrelated configuration change, dirty tree, unavailable/non-ancestor base, or omission of `--ratchet-activate-profiles` fails closed. The command stores no activation state or accepted findings; after the governed expansion lands, use ordinary `--ratchet` with exact matching context. Strict non-ratchet enforcement remains unchanged.
|
|
155
162
|
|
|
163
|
+
### Generated activation commit gate
|
|
164
|
+
|
|
165
|
+
A generated `pre-commit` hook recognizes only a staged `reject-new` strict profile expansion. It materializes the exact committed `HEAD` base and the staged index tree in an isolated worktree, runs both with the candidate's expanded profile set, and emits the same carried/new/resolved report. The commit proceeds only when `new` is empty. This is the supported bootstrap path for the activation commit; it never writes a baseline or weakens an ordinary hook. It fails closed unless the index has the explicit matching ratchet policy, the worktree has no unstaged or untracked state, the base profile list is a strict subset, and all non-profile policy is identical. All other commits retain ordinary strict hook enforcement.
|
|
166
|
+
|
|
156
167
|
## Docs-site theme
|
|
157
168
|
|
|
158
169
|
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.61",
|
|
3
3
|
"files": {
|
|
4
4
|
"HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
|
|
5
5
|
"PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
|
|
6
|
-
"README.md": "
|
|
6
|
+
"README.md": "97715ea62760048752774c5c6f9a34046e5a019595590fc71d5b007551385c7e",
|
|
7
7
|
"bunfig.toml": "b9fc65eca9014c5179380259d70e76385a6a80788fa9a2df5fb3eaa5554fd2fe",
|
|
8
8
|
"conventions/_profiles/architecture.yaml": "f1808768b720a4e476047e4a0912002ca673946562cea9aeef5d0c214828d93a",
|
|
9
9
|
"conventions/_profiles/coder.yaml": "3fb191556a64c9ebcf249516afa189afca24da9b22fd40b2fc66908196cdfb78",
|
|
@@ -1257,11 +1257,11 @@
|
|
|
1257
1257
|
"planner-schemas/wmbt.schema.json": "7a3a5693bfcf01340667c2be4a3c48e74f34b1af7e8dc13372c0de06b7f4fe12",
|
|
1258
1258
|
"relationships.yaml": "5860a308ebc3ee7fa8172fc2c6633984000ab3003f0ae4b6fa28db48879eda22",
|
|
1259
1259
|
"src/agent.ts": "6d6c6e75908af8fbab13432f0001b0374bb25cf3dee46ba218dfd67e0090cb56",
|
|
1260
|
-
"src/ci.ts": "
|
|
1260
|
+
"src/ci.ts": "646e72ff576d3921d85ecdd1d8c7821693832735be8949f5da254f3c345be032",
|
|
1261
1261
|
"src/cli.ts": "90e59edbb8a7ea116ad9e7b4ba56b5c612ac18ada05824e8e941048be4dc72c8",
|
|
1262
1262
|
"src/docs-capability.ts": "bdeeb414649049b1cd4fa8008cada3a576318be51c43bec91bfa46a8e250c270",
|
|
1263
1263
|
"src/enforce.ts": "7e6b3a1fc7c90f8e1836ab3e4655778fb569a63be5e7c199de44024fc003e6be",
|
|
1264
|
-
"src/hooks.ts": "
|
|
1264
|
+
"src/hooks.ts": "5992b94e26e93a9d3d8aece1336c5d36fce26682c86f4bf2775a3826ddf2670c",
|
|
1265
1265
|
"src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
|
|
1266
1266
|
"src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
|
|
1267
1267
|
"src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
|
|
@@ -1269,7 +1269,7 @@
|
|
|
1269
1269
|
"src/planner-schema-validator.ts": "ce529c0936171075e93c7539b3655fe895a98dd2973ebda5775958e6ebc316d9",
|
|
1270
1270
|
"src/planner-validators.ts": "99adeb65f579af923c11f21a8f55cfb9993b007fd8df59c5f764696b55379cfc",
|
|
1271
1271
|
"src/profile-registry.ts": "5a7761d324181ae27deb51f198d63648a4c99e858b4de98b54d35e6cf8a39fe7",
|
|
1272
|
-
"src/ratchet.ts": "
|
|
1272
|
+
"src/ratchet.ts": "801038b182615ed883197e0e29c98da9ac5f81bf4db4eb36b989065e32c99b6b",
|
|
1273
1273
|
"src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
|
|
1274
1274
|
"src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
|
|
1275
1275
|
"src/setup.ts": "38233265afedc63499bbef3b5403889651add335a70efd3813254ba53f4fc4fe",
|
|
@@ -1279,6 +1279,6 @@
|
|
|
1279
1279
|
"templates/agents/atdd-bun.integrity.test.ts": "dec6f6e5f65a08d9512fc703b367c163f8c9da1f6aa7fd466835c1ec30348a41",
|
|
1280
1280
|
"templates/docs/site.css": "482b3427e3a3cb02a85dc85cfbeac80592dfd53abf42fc92d6ea7071f22afc95",
|
|
1281
1281
|
"templates/github/atdd-bun-release.yml": "d9547e9e6ef3ae010d53d55314f6a54567f88bd900f7e50d994cd308601d2684",
|
|
1282
|
-
"templates/github/atdd-bun.yml": "
|
|
1282
|
+
"templates/github/atdd-bun.yml": "c267f47505eb7df2b73b279185f46912aac0f17dcd2c5a1c6c8e1a5834bdcb30"
|
|
1283
1283
|
}
|
|
1284
1284
|
}
|
package/package.json
CHANGED
package/src/ci.ts
CHANGED
|
@@ -12,5 +12,36 @@ const version = (await Bun.file(join(root, "package.json")).json() as { version:
|
|
|
12
12
|
export async function renderWorkflow(repo = process.cwd()) {
|
|
13
13
|
return (await readFile(template, "utf8")).replace("{{VERSION}}", version).replace("{{PROTECTED_BRANCHES}}", (await policy(repo)).protected_branches.map(branch => JSON.stringify(branch)).join(", "));
|
|
14
14
|
}
|
|
15
|
-
|
|
15
|
+
const githubPackagesRegistry = /^\s*@[\w.-]+:registry\s*=\s*https:\/\/npm\.pkg\.github\.com\/?\s*$/im;
|
|
16
|
+
const githubPackagesAuth = /^\s*\/\/npm\.pkg\.github\.com\/:_authToken\s*=\s*(.*?)\s*$/gim;
|
|
17
|
+
const githubPackagesTokenInterpolation = "${NODE_AUTH_TOKEN}";
|
|
18
|
+
|
|
19
|
+
/** Add only the npm-compatible environment interpolation required by an already-declared GitHub Packages scope.
|
|
20
|
+
* Registry ownership remains with the consumer: absent or other registry scopes are never invented or changed. */
|
|
21
|
+
async function githubPackagesNpmrc(repo: string) {
|
|
22
|
+
const path = join(repo, ".npmrc");
|
|
23
|
+
if (!existsSync(path)) return { ok: true, path, content: undefined as string | undefined };
|
|
24
|
+
const content = await readFile(path, "utf8");
|
|
25
|
+
// Authentication is sensitive even when no scope currently routes to GitHub Packages: validate every present
|
|
26
|
+
// assignment before deciding whether the generator should add a missing interpolation for a declared scope.
|
|
27
|
+
const assignments = [...content.matchAll(githubPackagesAuth)].map(match => match[1]);
|
|
28
|
+
if (assignments.length > 1) return { ok: false, path, message: ".npmrc must declare exactly one npm.pkg.github.com _authToken assignment to avoid ambiguous authentication" };
|
|
29
|
+
const current = assignments[0];
|
|
30
|
+
if (current !== undefined && current !== githubPackagesTokenInterpolation) return { ok: false, path, message: ".npmrc already configures npm.pkg.github.com authentication; replace it with the standard ${NODE_AUTH_TOKEN} interpolation instead of storing a credential" };
|
|
31
|
+
if (!githubPackagesRegistry.test(content) || current !== undefined) return { ok: true, path, content: undefined as string | undefined };
|
|
32
|
+
return { ok: true, path, content: `${content}${content.endsWith("\n") ? "" : "\n"}//npm.pkg.github.com/:_authToken=${githubPackagesTokenInterpolation}\n` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function ciInit(repo = process.cwd(), replace = false) {
|
|
36
|
+
const output = join(repo, ".github/workflows/atdd-bun.yml");
|
|
37
|
+
if (existsSync(output) && !replace) return { ok: false, message: `${output} exists; use --replace` };
|
|
38
|
+
let rendered: string;
|
|
39
|
+
try { rendered = await renderWorkflow(repo); } catch (error) { return { ok: false, message: `atdd-bun.yaml could not be parsed, so the workflow's push branches are unknown; fix the file first: ${String(error)}` }; }
|
|
40
|
+
const npmrc = await githubPackagesNpmrc(repo);
|
|
41
|
+
if (!npmrc.ok) return { ok: false, message: `${npmrc.path}: ${npmrc.message}` };
|
|
42
|
+
await mkdir(join(repo, ".github/workflows"), { recursive: true });
|
|
43
|
+
if (npmrc.content !== undefined) await writeFile(npmrc.path, npmrc.content);
|
|
44
|
+
await writeFile(output, rendered);
|
|
45
|
+
return { ok: true, message: npmrc.content === undefined ? output : `${output}\n${npmrc.path}` };
|
|
46
|
+
}
|
|
16
47
|
export async function ciStatus(repo = process.cwd()) { const output = join(repo, ".github/workflows/atdd-bun.yml"); return { ok: existsSync(output), message: output }; }
|
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() };
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Generated by @afokapu/atdd-bun {{VERSION}}. Do not edit; run `atdd-bun ci init --replace`.
|
|
2
2
|
name: ATdd-Bun
|
|
3
|
+
# The generated gate only needs checkout and package-download access. All other
|
|
4
|
+
# token capabilities are explicitly disabled.
|
|
5
|
+
permissions:
|
|
6
|
+
contents: read
|
|
7
|
+
packages: read
|
|
3
8
|
on:
|
|
4
9
|
pull_request:
|
|
5
10
|
merge_group:
|
|
@@ -16,9 +21,15 @@ jobs:
|
|
|
16
21
|
ATDD_BASE_REF: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'merge_group' && github.event.merge_group.base_sha || '' }}
|
|
17
22
|
steps:
|
|
18
23
|
- uses: actions/checkout@v4
|
|
19
|
-
with:
|
|
24
|
+
with:
|
|
25
|
+
fetch-depth: 0
|
|
26
|
+
persist-credentials: false
|
|
20
27
|
- uses: oven-sh/setup-bun@v2
|
|
28
|
+
# A consumer-owned .npmrc keeps registry scope. The Actions token exists
|
|
29
|
+
# only for this install step; it is neither written nor echoed.
|
|
21
30
|
- run: bun install --frozen-lockfile
|
|
31
|
+
env:
|
|
32
|
+
NODE_AUTH_TOKEN: ${{ github.token }}
|
|
22
33
|
- run: bun run atdd-bun integrity
|
|
23
34
|
- run: bun run atdd-bun all
|
|
24
35
|
- run: bun test
|