@afokapu/atdd-bun 0.10.60 → 0.10.62

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
@@ -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 |
@@ -96,8 +103,11 @@ the adoption that establishes the governed set, so a brownfield repository can d
96
103
  `profiles: [docs]` in an ordinary pull request. From then on, the integrity check reports, against
97
104
  the base branch, removing a profile from the list and removing the list itself. The second closes
98
105
  the two-step bypass `[docs, security]` → no list → `[docs]`. `init` writes a new `atdd-bun.yaml`
99
- with every profile listed, so a greenfield repository is governed from its first commit; trim the
100
- list before that commit to adopt gradually.
106
+ with every profile listed and a matching explicit `reject-new` ratchet policy, so a greenfield
107
+ repository is governed from its first commit. For an existing configuration with an explicit
108
+ profile list but no policy, `init` appends only the matching policy and preserves the selected
109
+ profiles and every other configuration field; keep the two selected-profile lists equal when
110
+ trimming them before the first commit to adopt gradually.
101
111
 
102
112
  ## Configuration
103
113
 
@@ -122,9 +132,13 @@ release: { enabled: false }
122
132
 
123
133
  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.
124
134
 
125
- ## Exact-base finding ratchet (opt-in)
135
+ ## Exact-base finding ratchet
126
136
 
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:
137
+ `bun run atdd-bun init` generates an explicit `reject-new` policy whose profiles exactly match
138
+ the generated selected profiles. For an existing explicit selection with no policy, the same
139
+ command appends that matching policy without changing the selection or other configuration.
140
+ Without a policy, strict enforcement remains the default; `bun run atdd-bun all` still fails for
141
+ every finding:
128
142
 
129
143
  ```yaml
130
144
  # atdd-bun.yaml
@@ -153,9 +167,19 @@ bun run atdd-bun --profile flow,traceability --ratchet --ratchet-activate-profil
153
167
 
154
168
  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
169
 
156
- ### Generated activation commit gate
170
+ ### Generated exact-base commit gate
171
+
172
+ A generated `pre-commit` hook recognizes a staged `reject-new` policy and materializes the exact
173
+ committed `HEAD` base plus the staged index tree in an isolated worktree. With an already-active,
174
+ unchanged explicit profile set and identical context, it runs ordinary exact-base comparison and
175
+ emits the carried/new/resolved report; the commit proceeds only when `new` is empty. A strict
176
+ profile expansion uses the same comparison as the one-time activation exception.
157
177
 
158
- 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.
178
+ The gate never writes a baseline or weakens policy outside that exact comparison. It fails closed
179
+ unless the index has the explicit matching ratchet policy, the worktree has no unstaged or
180
+ untracked state, and either the selected profiles/context are unchanged or the base profiles are
181
+ a strict subset with identical non-profile policy. Removals, context drift, unavailable/non-ancestor
182
+ bases, a bypass, waivers, mutable baselines, and new findings remain rejected.
159
183
 
160
184
  ## Docs-site theme
161
185
 
package/integrity.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "0.10.60",
2
+ "version": "0.10.62",
3
3
  "files": {
4
4
  "HOOK_AUDIT.md": "5329d840db37671b1918f688ead26865473b87db73dbc75f7c8b2a8bbe8d6d43",
5
5
  "PLANNER_PORT.md": "fb5935bac8b7ac18994de21e43ace3a5ef8cd55f85b0e3349fca261280054f11",
6
- "README.md": "ab10b690a87a3a654e7b16ad2528acdfc682e0b2eca31dddf1fae9dd69ef44c5",
6
+ "README.md": "207414ffc3947dcdc5fc778df63f3e870a5b6cca283e68751e439eed2c03fb0a",
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": "67e54d2cbfc44a9e42837d5e750af526d6255c539d3c452a4d5fca3226c0d9ac",
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": "5992b94e26e93a9d3d8aece1336c5d36fce26682c86f4bf2775a3826ddf2670c",
1264
+ "src/hooks.ts": "4816318553e8f6a96716d8f72f6a1976734fe8333ac4f1d2d8144ac71fefad81",
1265
1265
  "src/index.ts": "4081f8b08d1d3ee9e15a9964143e53d9cfb94a5616ab3e58a180d24cc16dc381",
1266
1266
  "src/integrity.ts": "f5ad22759eec675454df82cdcf791b1f081951f08ca5084ebdcb8cd82aa1ecef",
1267
1267
  "src/journey-docs.ts": "994d229376244c84212cff951032823068a76565dd2fd427b9d44e341bd14853",
@@ -1272,13 +1272,13 @@
1272
1272
  "src/ratchet.ts": "801038b182615ed883197e0e29c98da9ac5f81bf4db4eb36b989065e32c99b6b",
1273
1273
  "src/register.ts": "6a2f1c6b8df0dc9c8f45df8ec9fac2be29044e048b08c8ffe423410e94dadedd",
1274
1274
  "src/release.ts": "7b0fe444ee81f476f53b9c21b6c347ef588ee616c60211419cf984132be768ba",
1275
- "src/setup.ts": "38233265afedc63499bbef3b5403889651add335a70efd3813254ba53f4fc4fe",
1275
+ "src/setup.ts": "c6804f699808c01a849ea46489b0e9feab7991da4cb62a29edd439423e0a88c0",
1276
1276
  "src/telemetry-plan.ts": "cdb1e8bfff414f3ea81977388973da2cf6d89567d9e060dc9d5989a33600f153",
1277
1277
  "src/topology.ts": "9779ee48e1c453abb76363163d4ab363463fa14e359571139f6d1f1fcc51ae2c",
1278
1278
  "templates/agents/AGENTS.block.md": "84d19dfabedb1e05ccfb7b19a38644e87f193ae2b74755897f75f13971bb687e",
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": "404dc06b6ff4faf5eab909f9f77bac0ae58163d810c3928c4353b1f4b3529048"
1282
+ "templates/github/atdd-bun.yml": "c267f47505eb7df2b73b279185f46912aac0f17dcd2c5a1c6c8e1a5834bdcb30"
1283
1283
  }
1284
1284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afokapu/atdd-bun",
3
- "version": "0.10.60",
3
+ "version": "0.10.62",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/afokapu/atdd-bun.git"
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
- export async function ciInit(repo = process.cwd(), replace = false) { const output = join(repo, ".github/workflows/atdd-bun.yml"); if (existsSync(output) && !replace) return { ok: false, message: `${output} exists; use --replace` }; let rendered: string; 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)}` }; } await mkdir(join(repo, ".github/workflows"), { recursive: true }); await writeFile(output, rendered); return { ok: true, message: output }; }
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
@@ -40,8 +40,8 @@ const configAt = async (root: string) => {
40
40
  return existsSync(file) ? Bun.YAML.parse(await readFile(file, "utf8")) as Record<string, unknown> : {};
41
41
  };
42
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> {
43
+ /** A staged reject-new policy compares its exact HEAD: equal active profiles are ordinary ratchet flow; strict expansion is the one bootstrap exception. */
44
+ async function ratchetCommitGate(root: string): Promise<{ ok: boolean; message: string } | null> {
45
45
  const stagedConfig = await git(root, ["show", ":atdd-bun.yaml"]);
46
46
  if (stagedConfig.code) return null;
47
47
  let candidateConfig: Record<string, unknown>, policy;
@@ -59,14 +59,18 @@ async function profileActivationCommitGate(root: string): Promise<{ ok: boolean;
59
59
  if (checkedOut.code) return bad(`could not checkout ratchet activation base: ${checkedOut.err || checkedOut.out}`);
60
60
  const baseConfig = await configAt(scratch), candidateProfiles = stable(candidateConfig.profiles.map(normalizeProfileName));
61
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");
62
+ const policyMatches = stable(policy.profiles.map(normalizeProfileName)).join(",") === candidateProfiles.join(",");
63
+ const activation = isStrictProfileExpansion(baseProfiles, candidateProfiles) && activationContextDigest(baseConfig) === activationContextDigest(candidateConfig);
64
+ const unchanged = baseProfiles.join(",") === candidateProfiles.join(",") && contextDigest(baseConfig, baseProfiles) === contextDigest(candidateConfig, candidateProfiles);
65
+ if (candidateProfiles.some(profile => !concreteProfiles.includes(profile as typeof concreteProfiles[number])) || !policyMatches || (!activation && !unchanged)) return bad("ratchet reject-new requires either unchanged explicit profiles/context or an explicit monotonic profile expansion with identical non-profile context");
63
66
  const baseFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
64
67
  const patch = await git(root, ["diff", "--cached", "--binary"], undefined, true);
65
68
  if (patch.code) return bad(`could not read staged ratchet activation candidate: ${patch.err || patch.out}`);
66
69
  const applied = await git(scratch, ["apply", "--whitespace=nowarn"], patch.out);
67
70
  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");
71
+ const materialized = await configAt(scratch), materializedProfiles = stable(await enabledProfiles(scratch));
72
+ const materializedContext = activation ? activationContextDigest(materialized) === activationContextDigest(candidateConfig) : materializedProfiles.join(",") === candidateProfiles.join(",") && contextDigest(materialized, materializedProfiles) === contextDigest(candidateConfig, candidateProfiles);
73
+ if (!materializedContext) return bad("staged ratchet candidate did not materialize exactly");
70
74
  const candidateFindings = await enforce({ root: scratch, profiles: candidateProfiles as any });
71
75
  const normalize = (findings: Awaited<ReturnType<typeof enforce>>) => findings.map(finding => ({ ...finding, file: canonicalFindingPath(scratch, finding.file) }));
72
76
  const delta = compareFindings(normalize(baseFindings), normalize(candidateFindings));
@@ -88,7 +92,7 @@ export async function runHook(event: HookEvent, root = process.cwd(), args: stri
88
92
  const cfg = await policy(root), branch = (await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).out;
89
93
  if (["pre-commit", "pre-merge-commit"].includes(event) && cfg.protected_branches.includes(branch)) return bad(`protected branch ${branch} is blocked`);
90
94
  if (["pre-commit", "pre-merge-commit"].includes(event)) { const violation = await worktreeCommitPolicy(root, cfg.worktrees); if (violation) return bad(violation); }
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" }; }
95
+ 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 ratchet = await ratchetCommitGate(root); if (ratchet) return ratchet; return cfg.require_traceability || registries.length || generated.length ? validation(root, staged, false, registries.length > 0) : { ok: true, message: "ok" }; }
92
96
  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" }; }
93
97
  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); }
94
98
  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/setup.ts CHANGED
@@ -2,19 +2,33 @@ import { ciInit, ciStatus } from "./ci";
2
2
  import { hooksStatus, installHooks } from "./hooks";
3
3
  import { agentInit, agentStatus } from "./agent";
4
4
  import { integrityInit, integrityStatus } from "./integrity";
5
- import { defaultProfiles } from "./enforce";
5
+ import { concreteProfiles, defaultProfiles, normalizeProfileName } from "./enforce";
6
6
  import { existsSync } from "node:fs";
7
- import { writeFile } from "node:fs/promises";
7
+ import { readFile, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
 
10
10
  /** A new atdd-bun.yaml declares every profile explicitly. An absent list runs every profile but governs none, so
11
11
  * without this a greenfield repository's first `profiles:` line could switch most of them off unreported. A
12
12
  * brownfield repository trims the list before its first commit; after that, dropping one is a reported loosening.
13
- * An existing atdd-bun.yaml is never touched. */
13
+ * An existing explicit profile list gains only its matching generated reject-new policy; all other existing policy
14
+ * remains untouched. */
14
15
  export async function policyInit(root = process.cwd()) {
15
16
  const file = join(root, "atdd-bun.yaml");
16
- if (existsSync(file)) return { ok: true, message: `${file} kept` };
17
- await writeFile(file, `# Generated by atdd-bun init. The profiles this repository enforces; trim the list before the first commit to\n# adopt gradually. Once committed, removing a profile or the list is reported by the integrity check.\nprofiles: [${defaultProfiles.join(", ")}]\n`);
17
+ if (existsSync(file)) {
18
+ const text = await readFile(file, "utf8");
19
+ try {
20
+ const config = Bun.YAML.parse(text) as Record<string, unknown>;
21
+ const profiles = config && typeof config === "object" && !Array.isArray(config) && Array.isArray(config.profiles) && config.profiles.length && config.profiles.every(profile => typeof profile === "string") ? config.profiles as string[] : null;
22
+ const normalized = profiles?.map(normalizeProfileName);
23
+ const validProfiles = normalized && normalized.every(profile => concreteProfiles.includes(profile as typeof concreteProfiles[number])) && new Set(normalized).size === normalized.length;
24
+ if (profiles && validProfiles && config.ratchet === undefined) {
25
+ await writeFile(file, `${text.replace(/\s*$/, "")}\nratchet:\n mode: reject-new\n profiles: ${JSON.stringify(profiles)}\n`);
26
+ return { ok: true, message: `${file} added ratchet policy` };
27
+ }
28
+ } catch { /* Preserve malformed and non-policy consumer configuration for its normal validation path. */ }
29
+ return { ok: true, message: `${file} kept` };
30
+ }
31
+ await writeFile(file, `# Generated by atdd-bun init. The profiles this repository enforces; trim the list before the first commit to\n# adopt gradually. Once committed, removing a profile or the list is reported by the integrity check.\nprofiles: [${defaultProfiles.join(", ")}]\nratchet:\n mode: reject-new\n profiles: [${defaultProfiles.join(", ")}]\n`);
18
32
  return { ok: true, message: file };
19
33
  }
20
34
 
@@ -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: { fetch-depth: 0 }
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