@homeflare/config 0.12.0 → 0.12.1

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.
@@ -4,7 +4,7 @@ export type Problem = string;
4
4
  /** The command that makes a difference go away. Printed with every drift problem. */
5
5
  export declare const REFRESH_COMMAND = "bun run repo-shape:refresh";
6
6
  export interface DriftReport {
7
- /** Empty when the committed files are the rendered ones. */
7
+ /** Empty when the committed files are the rendered ones and no retired path lingers. */
8
8
  readonly problems: readonly Problem[];
9
9
  /** Paths whose committed text differs from the render, excluding excepted files. */
10
10
  readonly drifted: readonly string[];
@@ -1 +1 @@
1
- {"version":3,"file":"drift.d.ts","sourceRoot":"","sources":["../../src/repo-shape/drift.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAgB,SAAS,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAE9E,wFAAwF;AACxF,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAE7B,qFAAqF;AACrF,eAAO,MAAM,eAAe,+BAA+B,CAAC;AA6C5D,MAAM,WAAW,WAAW;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,oFAAoF;IACpF,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,SAAS,kBAAkB,EAAE,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,CA6BjG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAKpE"}
1
+ {"version":3,"file":"drift.d.ts","sourceRoot":"","sources":["../../src/repo-shape/drift.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAgB,SAAS,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAE9E,wFAAwF;AACxF,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC;AAE7B,qFAAqF;AACrF,eAAO,MAAM,eAAe,+BAA+B,CAAC;AA6D5D,MAAM,WAAW,WAAW;IAC1B,wFAAwF;IACxF,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,oFAAoF;IACpF,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,SAAS,kBAAkB,EAAE,CAAC;CAClD;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,WAAW,CAAC,CA8BjG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAKpE"}
@@ -13,6 +13,11 @@
13
13
  export declare function requireNonEmpty(field: string, value: string): string;
14
14
  export declare function requireSentence(field: string, value: string): string;
15
15
  export declare function requireIsoDate(value: string): string;
16
+ /**
17
+ * A retired file names the `@homeflare/config` release that stopped rendering it, so
18
+ * anyone reading `retired.ts` can find the changeset that made the call.
19
+ */
20
+ export declare function requireSemver(value: string): string;
16
21
  /**
17
22
  * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`
18
23
  * and resolves the newest 24.x; a fractional or negative value renders YAML the action
@@ -1 +1 @@
1
- {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../src/repo-shape/guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAQpE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKlD;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASlD"}
1
+ {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../../src/repo-shape/guards.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAIH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAQpE;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKnD;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKlD;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKpD;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASlD"}
@@ -6,8 +6,12 @@ export interface RefreshResult {
6
6
  readonly unchanged: readonly string[];
7
7
  /** Paths skipped because the shape declares an exception for them. */
8
8
  readonly skipped: readonly string[];
9
+ /** Retired paths deleted because they still carried the generated-file header. */
10
+ readonly removed: readonly string[];
11
+ /** Retired paths left alone because they did not — provably hand-written, not ours. */
12
+ readonly refused: readonly string[];
9
13
  }
10
- /** Write a repository's rendered files into `projectDir`. */
14
+ /** Write a repository's rendered files into `projectDir`, and clear its rendered fossils. */
11
15
  export declare function refreshRepoShape(projectDir: string, shape: RepoShape): Promise<RefreshResult>;
12
16
  /**
13
17
  * The `repo-shape:refresh` entry point. `--check` reports drift and exits non-zero
@@ -1 +1 @@
1
- {"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../../src/repo-shape/refresh.ts"],"names":[],"mappings":"AAiBA,OAAO,EAAE,KAAK,SAAS,EAAc,MAAM,YAAY,CAAC;AAGxD,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,6BAA6B;IAC7B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,6DAA6D;AAC7D,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,GACf,OAAO,CAAC,aAAa,CAAC,CAsBxB;AAiBD;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,MAAM,CAAC,CA+BjB"}
1
+ {"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../../src/repo-shape/refresh.ts"],"names":[],"mappings":"AAyBA,OAAO,EAAE,KAAK,SAAS,EAAc,MAAM,YAAY,CAAC;AAGxD,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,6BAA6B;IAC7B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,sEAAsE;IACtE,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,kFAAkF;IAClF,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,uFAAuF;IACvF,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,6FAA6F;AAC7F,wBAAsB,gBAAgB,CACpC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,GACf,OAAO,CAAC,aAAa,CAAC,CAwBxB;AA4CD;;;;GAIG;AACH,wBAAsB,YAAY,CAChC,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,SAAS,MAAM,EAAE,GACtB,OAAO,CAAC,MAAM,CAAC,CA2CjB"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A path `@homeflare/config` no longer renders, but may still find committed in a
3
+ * repository that refreshed before the retirement. Extend this union at the same time a
4
+ * path is added to `RETIRED_FILES` below — never reuse one already there.
5
+ */
6
+ export type RetiredPath = '.github/workflows/dependabot-automerge.yml';
7
+ export interface RetiredFile {
8
+ /** The path this package rendered, once. */
9
+ readonly path: RetiredPath;
10
+ /** The `@homeflare/config` version whose release stopped rendering it. */
11
+ readonly retiredIn: string;
12
+ /** Why, and which kit change did it — written at the retirement, like `except()`'s reason. */
13
+ readonly reason: string;
14
+ }
15
+ /** Every path `@homeflare/config` used to render, oldest retirement first. */
16
+ export declare const RETIRED_FILES: readonly RetiredFile[];
17
+ /**
18
+ * The substring every generated file's header has carried since this package's first
19
+ * renderer. Kept independent of the four current renderers' own copies of this text
20
+ * (`ci.ts`, `security.ts`, `dependabot.ts`, `companions.ts`) on purpose: a future wording
21
+ * change to the live header must not stop this module recognising a file rendered under
22
+ * the old one.
23
+ */
24
+ export declare const GENERATED_FILE_MARKER = "\uD83E\uDD16 RENDERED BY @homeflare/config";
25
+ /**
26
+ * Whether `content` is provably a file this package once wrote.
27
+ *
28
+ * ⚠️ THE MARKER HAS TO START A LINE OF ITS OWN, NOT MERELY OCCUR SOMEWHERE IN THE TEXT.
29
+ * Every real header carries it as a whole comment line — `# 🤖 RENDERED BY
30
+ * @homeflare/config — DO NOT EDIT…`, third line in every renderer in this directory, so
31
+ * this does not require it to be the FIRST line — but a bare `content.includes(...)`
32
+ * would also match a hand-written file that merely *talks about* the marker, e.g. a
33
+ * comment reading "this file used to be 🤖 RENDERED BY @homeflare/config before it was
34
+ * retired; keeping it by hand now" — adversarial review, 2026-09-24, caught this exact
35
+ * case before it shipped. Requiring the marker at the start of a trimmed line is what a
36
+ * sentence built around it, rather than a header line consisting of it, cannot satisfy.
37
+ * `repo-shape-retired.test.ts` asserts the mid-sentence form stays refused.
38
+ *
39
+ * ⚠️ STILL A PREFIX CHECK ON THAT LINE, NOT A FULL-LINE EXACT MATCH. The retired renderer
40
+ * varied its trailing header prose by `shape.runner` and by repository name, and a
41
+ * future wording change to what follows the marker on a live renderer's header must not
42
+ * stop this recognising a file rendered under the old wording — only the marker itself,
43
+ * `🤖 RENDERED BY @homeflare/config`, has been constant across every renderer this
44
+ * package has ever shipped.
45
+ */
46
+ export declare function wasRenderedByUs(content: string): boolean;
47
+ /**
48
+ * The message `driftInRepoShape` reports for a retired path that is still present, and
49
+ * `repoShapeCli --check` prints verbatim. Names the fix command either way — whether that
50
+ * command can actually remove the file depends on `wasRenderedByUs`, which only
51
+ * `refreshRepoShape` (holding the file's contents) can decide.
52
+ */
53
+ export declare function retiredFileProblem(file: RetiredFile): string;
54
+ //# sourceMappingURL=retired.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"retired.d.ts","sourceRoot":"","sources":["../../src/repo-shape/retired.ts"],"names":[],"mappings":"AA8BA;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,4CAA4C,CAAC;AAEvE,MAAM,WAAW,WAAW;IAC1B,4CAA4C;IAC5C,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAUD,8EAA8E;AAC9E,eAAO,MAAM,aAAa,EAAE,SAAS,WAAW,EAQ/C,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,+CAAqC,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAIxD;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,MAAM,CAK5D"}
@@ -39,6 +39,7 @@ export { HOMEFLARE_PATTERN, renderDependabot, THIRD_PARTY_COOLDOWN_DAYS, } from
39
39
  export { type DriftReport, type Problem, driftInRepoShape, exceptionSummary, REFRESH_COMMAND, } from './repo-shape/drift.ts';
40
40
  export { type RefreshResult, refreshRepoShape, repoShapeCli } from './repo-shape/refresh.ts';
41
41
  export { type RenderedRepo, type RepoShapePolicy, RENDERED_PATHS, renderRepoShape, } from './repo-shape/render.ts';
42
+ export { type RetiredFile, type RetiredPath, GENERATED_FILE_MARKER, RETIRED_FILES, retiredFileProblem, wasRenderedByUs, } from './repo-shape/retired.ts';
42
43
  export { renderSecurity } from './repo-shape/security.ts';
43
44
  export { type ExtraJob, type JobStep, type RenderedPath, type RepoRunner, type RepoShape, type RepoShapeException, type Stated, except, extraJob, isExcepted, runsOn, } from './repo-shape/shape.ts';
44
45
  //# sourceMappingURL=repo-shape.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"repo-shape.d.ts","sourceRoot":"","sources":["../src/repo-shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAAE,QAAQ,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAC3F,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,yBAAyB,GAC1B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,KAAK,aAAa,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC7F,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,cAAc,EACd,eAAe,GAChB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,OAAO,EACZ,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,KAAK,MAAM,EACX,MAAM,EACN,QAAQ,EACR,UAAU,EACV,MAAM,GACP,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"repo-shape.d.ts","sourceRoot":"","sources":["../src/repo-shape.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,EAAE,QAAQ,EAAE,kBAAkB,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAC3F,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,yBAAyB,GAC1B,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,GAChB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,KAAK,aAAa,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAC7F,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,cAAc,EACd,eAAe,GAChB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,WAAW,EAChB,qBAAqB,EACrB,aAAa,EACb,kBAAkB,EAClB,eAAe,GAChB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EACL,KAAK,QAAQ,EACb,KAAK,OAAO,EACZ,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,SAAS,EACd,KAAK,kBAAkB,EACvB,KAAK,MAAM,EACX,MAAM,EACN,QAAQ,EACR,UAAU,EACV,MAAM,GACP,MAAM,uBAAuB,CAAC"}
@@ -20,6 +20,12 @@ function requireIsoDate(value) {
20
20
  }
21
21
  return value;
22
22
  }
23
+ function requireSemver(value) {
24
+ if (!/^\d+\.\d+\.\d+$/.test(value)) {
25
+ throw new Error(`repo-shape: retiredIn must be a released x.y.z, got ${JSON.stringify(value)}`);
26
+ }
27
+ return value;
28
+ }
23
29
  function requireMajor(value) {
24
30
  if (!Number.isInteger(value) || value <= 0) {
25
31
  throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);
@@ -549,6 +555,30 @@ var RENDERED_PATHS = [
549
555
  ".github/workflows/security.yml"
550
556
  ];
551
557
 
558
+ // src/repo-shape/retired.ts
559
+ function retire(file) {
560
+ return {
561
+ path: file.path,
562
+ reason: requireSentence("retired reason", file.reason),
563
+ retiredIn: requireSemver(file.retiredIn)
564
+ };
565
+ }
566
+ var RETIRED_FILES = [
567
+ retire({
568
+ path: ".github/workflows/dependabot-automerge.yml",
569
+ reason: "kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper " + "carries a kit release into each consumer now, over the kit's own release workflow",
570
+ retiredIn: "0.12.0"
571
+ })
572
+ ];
573
+ var GENERATED_FILE_MARKER = "\uD83E\uDD16 RENDERED BY @homeflare/config";
574
+ function wasRenderedByUs(content) {
575
+ return content.split(`
576
+ `).some((line) => line.trimStart().startsWith(`# ${GENERATED_FILE_MARKER}`));
577
+ }
578
+ function retiredFileProblem(file) {
579
+ return `${file.path}: retired in @homeflare/config@${file.retiredIn} (${file.reason}) ` + "but still present \u2014 run `bun run repo-shape:refresh` to remove it";
580
+ }
581
+
552
582
  // src/repo-shape/drift.ts
553
583
  var REFRESH_COMMAND = "bun run repo-shape:refresh";
554
584
  function normalize(text) {
@@ -573,13 +603,23 @@ function duplicateExceptions(exceptions) {
573
603
  }
574
604
  return [...duplicated].map((file) => `${file}: declared as an exception more than once \u2014 keep one, with one reason`);
575
605
  }
606
+ async function retiredFilesPresent(projectDir) {
607
+ const problems = [];
608
+ for (const file of RETIRED_FILES) {
609
+ if (await readIfPresent(`${projectDir}/${file.path}`) !== undefined) {
610
+ problems.push(retiredFileProblem(file));
611
+ }
612
+ }
613
+ return problems;
614
+ }
576
615
  async function driftInRepoShape(projectDir, shape) {
577
616
  const rendered = renderRepoShape(shape);
578
617
  const exceptions = shape.exceptions ?? [];
579
618
  const excepted = new Map(exceptions.map((exception) => [exception.file, exception]));
580
619
  const problems = [
581
620
  ...duplicateExceptions(exceptions),
582
- ...staleExceptions(rendered, exceptions)
621
+ ...staleExceptions(rendered, exceptions),
622
+ ...await retiredFilesPresent(projectDir)
583
623
  ];
584
624
  const drifted = [];
585
625
  for (const [path, expected] of Object.entries(rendered.files)) {
@@ -621,7 +661,25 @@ async function refreshRepoShape(projectDir, shape) {
621
661
  await Bun.write(target, contents);
622
662
  written.push(path);
623
663
  }
624
- return { skipped, unchanged, written };
664
+ const { refused, removed } = await clearRetiredFiles(projectDir);
665
+ return { refused, removed, skipped, unchanged, written };
666
+ }
667
+ async function clearRetiredFiles(projectDir) {
668
+ const removed = [];
669
+ const refused = [];
670
+ for (const retiredFile of RETIRED_FILES) {
671
+ const target = `${projectDir}/${retiredFile.path}`;
672
+ const file = Bun.file(target);
673
+ if (!await file.exists())
674
+ continue;
675
+ if (wasRenderedByUs(await file.text())) {
676
+ await file.delete();
677
+ removed.push(retiredFile.path);
678
+ } else {
679
+ refused.push(retiredFile.path);
680
+ }
681
+ }
682
+ return { refused, removed };
625
683
  }
626
684
  async function say(stream, lines) {
627
685
  if (lines.length === 0)
@@ -652,16 +710,20 @@ async function repoShapeCli(projectDir, shape, argv) {
652
710
  await say(Bun.stdout, [
653
711
  ...result.written.map((path) => `wrote ${path}`),
654
712
  ...result.unchanged.map((path) => `ok ${path}`),
655
- ...result.skipped.map((path) => `excepted ${path}`)
713
+ ...result.skipped.map((path) => `excepted ${path}`),
714
+ ...result.removed.map((path) => `removed ${path} (retired; carried our header)`)
656
715
  ]);
716
+ await say(Bun.stderr, result.refused.map((path) => `refused ${path} \u2014 present but not provably ours; not deleted. See docs/repo-shape-retired.md.`));
657
717
  return 0;
658
718
  }
659
719
  export {
660
720
  ACTIONLINT_VERSION,
661
721
  BUN_VERSION,
722
+ GENERATED_FILE_MARKER,
662
723
  HOMEFLARE_PATTERN,
663
724
  REFRESH_COMMAND,
664
725
  RENDERED_PATHS,
726
+ RETIRED_FILES,
665
727
  THIRD_PARTY_COOLDOWN_DAYS,
666
728
  driftInRepoShape,
667
729
  except,
@@ -676,8 +738,10 @@ export {
676
738
  renderRepoShape,
677
739
  renderSecurity,
678
740
  repoShapeCli,
679
- runsOn
741
+ retiredFileProblem,
742
+ runsOn,
743
+ wasRenderedByUs
680
744
  };
681
745
 
682
- //# debugId=27828123E35E339964756E2164756E21
746
+ //# debugId=B40D338DD6A7FD1664756E2164756E21
683
747
  //# sourceMappingURL=repo-shape.js.map
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/repo-shape/guards.ts", "../src/repo-shape/shape.ts", "../src/repo-shape/yaml.ts", "../src/repo-shape/ci.ts", "../src/repo-shape/companions.ts", "../src/repo-shape/dependabot.ts", "../src/repo-shape/security.ts", "../src/repo-shape/render.ts", "../src/repo-shape/drift.ts", "../src/repo-shape/refresh.ts"],
3
+ "sources": ["../src/repo-shape/guards.ts", "../src/repo-shape/shape.ts", "../src/repo-shape/yaml.ts", "../src/repo-shape/ci.ts", "../src/repo-shape/companions.ts", "../src/repo-shape/dependabot.ts", "../src/repo-shape/security.ts", "../src/repo-shape/render.ts", "../src/repo-shape/retired.ts", "../src/repo-shape/drift.ts", "../src/repo-shape/refresh.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * The runtime guards `repo-shape` needs and its types cannot express.\n *\n * ★ EXTRACTED FROM `shape.ts`, NOT INVENTED HERE. The types carry one half of the\n * contract — `Stated<R>` refuses an empty or computed reason at compile time — and\n * these carry the other half: the cases a literal type still lets through. Keeping\n * them in one file is what lets `shape.ts` stay the declaration and nothing else.\n *\n * ⚠️ EVERY MESSAGE STARTS `repo-shape:` AND NAMES THE FIELD. These throw at render or at\n * declaration, far from the workflow file they would otherwise break at job time, so\n * the message is the only thing pointing back at the call that caused it.\n */\n\nconst SENTENCE = 12;\n\nexport function requireNonEmpty(field: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);\n return trimmed;\n}\n\nexport function requireSentence(field: string, value: string): string {\n const trimmed = value.trim();\n // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`\n // lets it through and only this does not. Type and guard cover different halves.\n if (trimmed.length < SENTENCE) {\n throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);\n }\n return trimmed;\n}\n\nexport function requireIsoDate(value: string): string {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\n/**\n * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`\n * and resolves the newest 24.x; a fractional or negative value renders YAML the action\n * accepts and then fails to resolve, mid-job, on the runner.\n */\nexport function requireMajor(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);\n }\n return value;\n}\n\n/** `timeout-minutes` must be a positive whole number of minutes. */\nexport function requireMinutes(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: timeout must be a positive whole minute count, got ${value}`);\n }\n return value;\n}\n\nexport function requireJobId(value: string): string {\n // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow\n // GitHub rejects at parse time, which reports as \"workflow file issue\" with no line.\n if (!/^[a-z][a-z0-9_-]*$/.test(value)) {\n throw new Error(\n `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,\n );\n }\n return value;\n}\n",
5
+ "/**\n * The runtime guards `repo-shape` needs and its types cannot express.\n *\n * ★ EXTRACTED FROM `shape.ts`, NOT INVENTED HERE. The types carry one half of the\n * contract — `Stated<R>` refuses an empty or computed reason at compile time — and\n * these carry the other half: the cases a literal type still lets through. Keeping\n * them in one file is what lets `shape.ts` stay the declaration and nothing else.\n *\n * ⚠️ EVERY MESSAGE STARTS `repo-shape:` AND NAMES THE FIELD. These throw at render or at\n * declaration, far from the workflow file they would otherwise break at job time, so\n * the message is the only thing pointing back at the call that caused it.\n */\n\nconst SENTENCE = 12;\n\nexport function requireNonEmpty(field: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);\n return trimmed;\n}\n\nexport function requireSentence(field: string, value: string): string {\n const trimmed = value.trim();\n // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`\n // lets it through and only this does not. Type and guard cover different halves.\n if (trimmed.length < SENTENCE) {\n throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);\n }\n return trimmed;\n}\n\nexport function requireIsoDate(value: string): string {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\n/**\n * A retired file names the `@homeflare/config` release that stopped rendering it, so\n * anyone reading `retired.ts` can find the changeset that made the call.\n */\nexport function requireSemver(value: string): string {\n if (!/^\\d+\\.\\d+\\.\\d+$/.test(value)) {\n throw new Error(`repo-shape: retiredIn must be a released x.y.z, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\n/**\n * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`\n * and resolves the newest 24.x; a fractional or negative value renders YAML the action\n * accepts and then fails to resolve, mid-job, on the runner.\n */\nexport function requireMajor(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);\n }\n return value;\n}\n\n/** `timeout-minutes` must be a positive whole number of minutes. */\nexport function requireMinutes(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: timeout must be a positive whole minute count, got ${value}`);\n }\n return value;\n}\n\nexport function requireJobId(value: string): string {\n // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow\n // GitHub rejects at parse time, which reports as \"workflow file issue\" with no line.\n if (!/^[a-z][a-z0-9_-]*$/.test(value)) {\n throw new Error(\n `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,\n );\n }\n return value;\n}\n",
6
6
  "/**\n * `RepoShape` — one HomeFlare repository's tooling, as a value.\n *\n * ★ WHY THIS TYPE EXISTS. Measured across the estate on 2026-09-22: 13 repositories each\n * carry a hand-written `.github/workflows/ci.yml`, `security.yml`, `.github/actionlint.yaml`\n * and `.changeset/config.json`. The changeset configs were byte-identical apart from the\n * repository name in 11 of 13. The security workflows were not: five still carried\n * `push: branches: [main]` that the other eight had removed, five were missing the\n * `concurrency:` block entirely, two were missing `pull-requests: read` (whose absence\n * makes every pull-request scan fail 403), the gitleaks action was pinned at `@v2` in\n * five and `@v3` in eight, and the weekly cron minute took three different values. Not\n * one of those differences was a decision. They are what happens when the same edit is\n * applied by hand thirteen times.\n *\n * ⛔ SO: A DIFFERENCE IS EITHER AN INPUT OR AN EXCEPTION. Nothing else. A repository that\n * needs something the standard does not give it either widens this type — and every\n * repository gets the widening — or declares an exception with a stated reason. There\n * is no third option where a file is quietly edited, because `drift.ts` fails on it.\n *\n * ⚠️ THE REASON IS ENFORCED BY THE COMPILER, NOT BY REVIEW. `except()` and `extraJob()`\n * below refuse an empty reason and refuse a reason read out of a variable, so the text\n * has to be written at the exception. See `Stated`.\n */\n\nimport {\n requireIsoDate,\n requireJobId,\n requireMajor,\n requireMinutes,\n requireNonEmpty,\n requireSentence,\n} from './guards.ts';\n\n/** Where a repository's jobs run. */\nexport type RepoRunner =\n /**\n * `[self-hosted, homeflare-mini]` — the interim Linux arm64 runner on the Mac mini.\n * ⚠️ GitHub-hosted runners are refused for this account (billing lock, 2026-09-22).\n */\n | 'mini'\n /** `ubuntu-latest`. Public repositories, where GitHub-hosted minutes are free. */\n | 'github';\n\n/** One step in a rendered job. Either a `uses:` or a `run:`, never both. */\nexport type JobStep = {\n readonly name?: string;\n /**\n * The step's `if:` condition, verbatim.\n *\n * ★ WIDENED RATHER THAN EXCEPTED, 2026-09-22. homeflare-kit's `consumer smoke test`\n * ends with a step that renders the packed tarball sizes onto the run summary, and\n * it carries `if: always()` on purpose — a FAILED smoke test is exactly when the\n * sizes are worth reading. Without this field kit had two options and both were\n * worse: drop the condition, losing the summary on the only runs that need it, or\n * `except('.github/workflows/ci.yml')`, which buys one repository its file back and\n * costs the estate the guarantee on it. One optional key gives every repository the\n * same freedom, which is the order of preference this module documents.\n * ⚠️ NOT VALIDATED. GitHub's expression grammar is the vendor's; a bad condition is\n * caught by `actionlint` in the `workflow lint` job, which is where it belongs.\n */\n readonly if?: string;\n readonly env?: Readonly<Record<string, string>>;\n} & (\n | {\n readonly uses: string;\n readonly with?: Readonly<Record<string, string | number>>;\n readonly run?: never;\n }\n | { readonly run: string; readonly uses?: never; readonly with?: never }\n);\n\n/**\n * ⛔ A REASON MUST BE WRITTEN, NOT COMPUTED. `'' extends R` is true for the empty literal\n * AND for the wide `string` type, so both collapse to `never` and fail to typecheck:\n * `reason: ''` is refused, and so is `reason: someVariable`. Only a string literal\n * written at the call site survives — which is the whole point, because a reason\n * assembled at runtime is a reason nobody reads in the diff.\n * Verified against TypeScript 7.0.2 on 2026-09-22; `tests/repo-shape-reason.test.ts`\n * compiles the refusals and asserts they still fail.\n */\nexport type Stated<R extends string> = '' extends R ? never : R;\n\n/** Paths this package renders. A deviation names one of these, not an arbitrary file. */\nexport type RenderedPath =\n | '.changeset/config.json'\n | '.github/actionlint.yaml'\n | '.github/dependabot.yml'\n | '.github/workflows/ci.yml'\n | '.github/workflows/security.yml';\n\nexport interface RepoShapeException {\n /** The rendered file this repository does not take from the renderer. */\n readonly file: RenderedPath;\n /** Why — written at the exception, in a sentence a stranger can act on. */\n readonly reason: string;\n /** `YYYY-MM-DD` the exception was taken, so a stale one is visible. */\n readonly since: string;\n}\n\ninterface ExceptionInput {\n readonly file: RenderedPath;\n readonly reason: string;\n readonly since: string;\n}\n\n/**\n * Declare that this repository keeps its own copy of a rendered file.\n *\n * except({\n * file: '.github/workflows/ci.yml',\n * reason: 'Payload needs Node >=24.15, which the rendered job does not install',\n * since: '2026-09-22',\n * })\n *\n * ⛔ THE DRIFT CHECK STOPS CHECKING THAT FILE. That is the trade: an exception buys the\n * freedom to hand-edit one file and pays for it by losing the guarantee on that file.\n * Prefer widening `RepoShape` — then every repository benefits and nothing is lost.\n */\nexport function except<const E extends ExceptionInput>(\n exception: E & { readonly reason: Stated<E['reason']> },\n): RepoShapeException {\n return {\n file: exception.file,\n reason: requireSentence('reason', exception.reason),\n since: requireIsoDate(exception.since),\n };\n}\n\nexport interface ExtraJob {\n /** The job key in `jobs:`, e.g. `package`. Lower-case, dashes allowed. */\n readonly id: string;\n /**\n * The job's `name:`, which is also the status-check context. It joins the `ci`\n * aggregate's `needs`, so the branch ruleset still requires exactly one check.\n */\n readonly name: string;\n /** Why this repository has a job the other twelve do not. */\n readonly reason: string;\n /** Steps after checkout. The prologue (checkout, bun, install) is rendered for you. */\n readonly steps: readonly JobStep[];\n /** Job ids this one waits for. Default: none, so it runs beside `check`. */\n readonly needs?: readonly string[];\n /** `false` skips the rendered bun prologue — for a job that needs another toolchain. */\n readonly bun?: boolean;\n /**\n * `timeout-minutes:` for this job. Omitted takes GitHub's 360-minute default.\n *\n * ⚠️ AN INPUT BECAUSE A HUNG JOB IS NOT A FAILED JOB. Measured 2026-09-22:\n * homeflare-blog's `runtime` job drives Playwright against a local workerd, and a\n * browser that never reaches its first paint holds a self-hosted slot for six hours\n * rather than reporting red. On a 3-slot pool that is the whole pool. Only a job\n * that starts something with its own wait — a browser, a server, a container — needs\n * this; `check` does not, because `bun run check` exits.\n */\n readonly timeout?: number;\n}\n\ninterface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun' | 'timeout'> {\n readonly needs?: readonly string[];\n readonly bun?: boolean;\n readonly timeout?: number;\n}\n\n/**\n * Declare a job this repository needs and the standard does not have.\n * ⛔ Same rule as `except`: the reason is a written literal or it does not compile.\n */\nexport function extraJob<const J extends ExtraJobInput>(\n job: J & { readonly reason: Stated<J['reason']> },\n): ExtraJob {\n if (job.steps.length === 0) {\n throw new Error(`repo-shape: extra job \"${job.id}\" has no steps`);\n }\n return {\n bun: job.bun ?? true,\n id: requireJobId(job.id),\n // ⚠️ A DISPLAY NAME IS NOT A SENTENCE. `build` and `consumer smoke test` are both\n // correct job names; only the REASON has to be prose, because only the reason is\n // there for a reader rather than for the checks list.\n name: requireNonEmpty('name', job.name),\n needs: [...(job.needs ?? [])],\n reason: requireSentence('reason', job.reason),\n steps: [...job.steps],\n ...(job.timeout === undefined ? {} : { timeout: requireMinutes(job.timeout) }),\n };\n}\n\nexport interface RepoShape {\n /** Repository owner — a user or organization login. */\n readonly owner: string;\n /** Repository name, which is also the checkout directory name. */\n readonly repository: string;\n /** Where its jobs run. */\n readonly runner: RepoRunner;\n /**\n * `true` when the repository publishes an npm tarball. It decides `access` in the\n * changeset config and whether `privatePackages` is written at all — the two keys\n * that differ between `homeflare-kit` and every other repository.\n */\n readonly publishes: boolean;\n /**\n * Node major to install before Bun, for a repository whose own gate needs a real\n * `node` on `PATH`. Omit it — twelve of fourteen repositories are Bun-only.\n *\n * ⛔ AN INPUT, NOT AN EXCEPTION, AND THE MEASUREMENT SAYS WHY. The mini's job image\n * carries no node at all (ubuntu-latest always did), so a Bun-only prologue is right\n * for most of the estate and *silently wrong* for two repositories:\n * · homeflare-alerts — tests/alchemy-import.test.ts spawns `node` to prove the\n * modules load the way the Alchemy CLI (`node …/cli.js`) loads them. Without it,\n * three tests fail with `Executable not found in $PATH: \"node\"` (measured on the\n * runner, 2026-09-22).\n * · homeflare-blog — Payload requires Node >= 24.15, so every lane needs it.\n * Both repositories carried the same hand-written `actions/setup-node@v6` block\n * before this existed. Excepting `ci.yml` instead would hand the estate's two most\n * complicated CI files back to hand-editing, which is the opposite of the point.\n *\n * ⚠️ `package-manager-cache: false` IS RENDERED WITH IT. Bun does the installing here;\n * letting setup-node prime an npm cache costs time and caches nothing anyone reads.\n */\n readonly node?: number;\n /** Jobs beyond `check` and `workflows`. Each carries its own stated reason. */\n readonly extraJobs?: readonly ExtraJob[];\n /** Rendered files this repository keeps its own copy of, each with a reason. */\n readonly exceptions?: readonly RepoShapeException[];\n}\n\n/**\n * The validated Node major this shape asks for, or `undefined` for a Bun-only prologue.\n * ★ `RepoShape` is a plain object, so this is where `node:` is checked — at render, not\n * at declaration. A bad value fails the refresh rather than the job.\n */\nexport function nodeMajor(shape: RepoShape): number | undefined {\n return shape.node === undefined ? undefined : requireMajor(shape.node);\n}\n\n/** `runs-on:` for a runner. */\nexport function runsOn(runner: RepoRunner): string {\n return runner === 'mini' ? '[self-hosted, homeflare-mini]' : 'ubuntu-latest';\n}\n\n/** Whether a rendered path is excepted by this shape. */\nexport function isExcepted(shape: RepoShape, file: RenderedPath): boolean {\n return (shape.exceptions ?? []).some((exception) => exception.file === file);\n}\n",
7
7
  "/**\n * The smallest YAML writer that renders a job's steps, and nothing else.\n *\n * ★ NOT A YAML LIBRARY, DELIBERATELY. A general serializer would take a dependency every\n * consumer of `@homeflare/config` inherits, and it would strip the comments — which in\n * this house are the product. The rendered workflows are written as text with holes;\n * only the step lists, whose shape varies per repository, go through here.\n *\n * ⚠️ Bun.YAML.stringify EXISTS (`Object.keys(Bun.YAML)` is `[\"parse\", \"stringify\"]`,\n * measured against Bun 1.4.0 on 2026-09-23) but its output does not fit these rules:\n * (a) a multi-line `run:` comes out as a double-quoted string with `\\n` escapes, e.g.\n * `run: \"echo a\\necho b\\n\"`, not a `|` block scalar; (b) `09:00` comes out UNQUOTED,\n * e.g. `cron: 09:00`, the YAML 1.1 sexagesimal trap the `scalar()` comment below guards\n * against; (c) a mapping key is followed by a trailing space, e.g. `\"steps: \\n - ...\"`;\n * and (d) a plain JS object has nowhere to attach a comment, so it cannot carry one. The\n * tests parse what this writes with `Bun.YAML.parse` and compare structures, so a\n * malformed emission fails rather than shipping.\n */\nimport type { JobStep } from './shape.ts';\n\n/** Two spaces per level, the house indent and GitHub's own. */\nconst INDENT = ' ';\n\nfunction indent(depth: number): string {\n return INDENT.repeat(depth);\n}\n\n/**\n * ⛔ QUOTE ANYTHING YAML WOULD RE-READ AS SOMETHING ELSE. `09:00` is a sexagesimal number\n * in YAML 1.1 and `on`/`no` are booleans; an unquoted value that looks like either\n * reaches GitHub as the wrong type, and the symptom is a schedule that never fires\n * rather than an error.\n */\nconst PLAIN = /^[A-Za-z0-9_./][A-Za-z0-9_ ./@:+-]*$/;\nconst YAML_KEYWORD =\n /^(y|Y|n|N|on|On|ON|no|No|NO|yes|Yes|YES|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/;\n\nfunction scalar(value: string | number): string {\n if (typeof value === 'number') return String(value);\n if (value === '') return \"''\";\n const plain =\n PLAIN.test(value) &&\n !YAML_KEYWORD.test(value) &&\n !value.includes(': ') &&\n !value.includes(' #') &&\n // ⚠️ A LEADING DIGIT PLUS A COLON IS A YAML 1.1 SEXAGESIMAL NUMBER, not a string:\n // `09:00` would reach GitHub as an integer, and a schedule set to an integer\n // simply never fires. `bun run build:web` is safe because it does not start with\n // a digit, which is why this is narrower than \"contains a colon\".\n !/^\\d.*:/.test(value) &&\n value.trimEnd() === value;\n return plain ? value : `'${value.replaceAll(\"'\", \"''\")}'`;\n}\n\nfunction renderMapping(\n entries: Readonly<Record<string, string | number>>,\n depth: number,\n): string[] {\n return Object.entries(entries).map(([key, value]) => `${indent(depth)}${key}: ${scalar(value)}`);\n}\n\n/**\n * Trim trailing `\\n` characters the way `command.replace(/\\n+$/, '')` used to, but\n * linear instead of backtracking — the same pattern as `normalizeBaseUrl` in\n * `packages/distilled-netbox/src/credentials.ts`. Exported so a test can compare it\n * against the old regex directly.\n *\n * ⚠️ Linear on purpose: a `/\\n+$/` regex backtracks polynomially on a long run of\n * \"\\n\" that is not at the end (CodeQL js/polynomial-redos), and `command` here is\n * repository-configured step text.\n */\nexport function trimTrailingNewlines(value: string): string {\n let end = value.length;\n while (end > 0 && value.charCodeAt(end - 1) === 10) end--;\n return value.slice(0, end);\n}\n\n/**\n * ★ BLOCK SCALAR FOR EVERY MULTI-LINE `run:`. A folded or quoted form would join the\n * lines, and a shell script whose `if` and `then` end up on one line is a syntax error\n * at job time rather than at lint time. `|` keeps them exactly as written.\n */\nfunction renderRun(command: string, depth: number): string[] {\n const lines = trimTrailingNewlines(command).split('\\n');\n if (lines.length === 1) return [`${indent(depth)}run: ${scalar(lines[0] ?? '')}`];\n return [`${indent(depth)}run: |`, ...lines.map((line) => `${indent(depth + 1)}${line}`)];\n}\n\n/** One step, as the lines of a `steps:` list item at `depth`. */\nexport function renderStep(step: JobStep, depth: number): string[] {\n const lines: string[] = [];\n const body =\n step.uses === undefined\n ? renderRun(step.run ?? '', depth + 1)\n : [`${indent(depth + 1)}uses: ${step.uses}`];\n\n // ★ `name:` then `if:` then the body, because that is the order a reader scans: what\n // this step is, whether it runs, what it does. All three are ordinary mapping keys\n // to GitHub, so the order is for the person reading the diff, not the parser.\n const head: string[] = [];\n if (step.name !== undefined) head.push(`${indent(depth + 1)}name: ${scalar(step.name)}`);\n if (step.if !== undefined) head.push(`${indent(depth + 1)}if: ${scalar(step.if)}`);\n\n // ⚠️ The first line of the item carries the dash, whichever key it turns out to be —\n // an unnamed, unconditional step still inlines its `run:` or `uses:` after the dash\n // and lets any block body follow at its own indent.\n const [first = '', ...rest] = [...head, ...body];\n lines.push(`${indent(depth)}- ${first.trimStart()}`, ...rest);\n\n if (step.with !== undefined && Object.keys(step.with).length > 0) {\n lines.push(`${indent(depth + 1)}with:`, ...renderMapping(step.with, depth + 2));\n }\n if (step.env !== undefined && Object.keys(step.env).length > 0) {\n lines.push(`${indent(depth + 1)}env:`, ...renderMapping(step.env, depth + 2));\n }\n return lines;\n}\n\n/** A whole `steps:` list, already indented for a job at `depth`. */\nexport function renderSteps(steps: readonly JobStep[], depth: number): string {\n return steps.flatMap((step) => renderStep(step, depth)).join('\\n');\n}\n",
8
8
  "/**\n * `.github/workflows/ci.yml`, rendered.\n *\n * ★ THE COMMENTS ARE PART OF THE RENDER, NOT DECORATION. Thirteen repositories carried\n * thirteen hand-edited copies of the same reasoning; measured 2026-09-22, the header\n * comment alone had four different wordings and the actionlint block had three. Written\n * here once, every repository gets the same explanation, and correcting it is one edit.\n *\n * ⛔ THE AGGREGATE `ci` JOB IS THE ONLY REQUIRED CHECK. Every other job feeds it through\n * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`\n * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.\n */\nimport type { ExtraJob, JobStep, RepoShape } from './shape.ts';\nimport { nodeMajor, runsOn } from './shape.ts';\nimport { renderSteps } from './yaml.ts';\n\n/** Bun the whole estate is pinned to. One line, one place. */\nexport const BUN_VERSION = '1.4.0';\n/** actionlint the `workflow lint` job runs, and the mini's job image preloads. */\nexport const ACTIONLINT_VERSION = '1.7.12';\n\nconst CHECKOUT = 'actions/checkout@v7';\nconst SETUP_BUN = 'oven-sh/setup-bun@v2';\nconst SETUP_NODE = 'actions/setup-node@v6';\n\nfunction runnerNote(shape: RepoShape): string {\n if (shape.runner !== 'mini') {\n return \"# ⚠️ RUNS ON GitHub-HOSTED `ubuntu-latest`. Only a public repository should: hosted minutes\\n# are refused for this account's private repos (billing lock, 2026-09-22).\\n\";\n }\n return [\n '# ⚠️ RUNS ON THE MINI (`[self-hosted, homeflare-mini]`): GitHub-hosted runners are refused for',\n '# this account (billing lock, 2026-09-22), so every job here runs in a one-job Linux arm64',\n \"# container started by homeflare-mini's src/ci-runner (docs/ci-runner.md there). Each action\",\n `# below was checked for linux/arm64. The job image preloads bun ${\n BUN_VERSION\n } where setup-bun`,\n '# looks, gh, actionlint, gitleaks, typos and shfmt; no node on PATH.',\n '',\n ].join('\\n');\n}\n\nconst HEADER = `# Pull requests: is this code correct?\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Its input is this repository's \\`repo-shape.ts\\`. Change that, then:\n# bun run repo-shape:refresh\n# A hand edit is reverted by the next refresh and fails \\`bun run check\\` before that.\n# A file this repository must own outright is declared as an \\`except({...})\\` with a\n# reason, which stops the check from comparing it — see @homeflare/config/repo-shape.\n#\n# ★ SPLIT BY CONCERN, NOT ONE BIG JOB — AND NOT ONE JOB PER COMMAND EITHER: lanes that\n# share a prologue are steps inside \\`check\\`, so the install is paid once.\n# ⛔ EVERY ACTION IS FIRST-PARTY OR THE VENDOR'S OWN, PINNED TO A MAJOR TAG.\n`;\n\nconst TRIGGER = `name: ci\n\non:\n # ⛔ NO \\`push: branches: [main]\\`. Every commit reaches main through a pull request whose\n # \\`ci\\` had to be green — the branch ruleset requires it and carries no bypass actors —\n # so a second run on the squash commit recomputed an answer it already had. Measured\n # 2026-09-15..22 across the estate: 613 of 619 main-push runs had a head_sha identical\n # to the merge_commit_sha of an already-green PR. That duplication was ~41% of the Mac\n # mini's entire CI load.\n # ⚠️ WHAT THIS GIVES UP, SAID OUT LOUD: the ~4% of merges whose base DID move between the\n # PR run and the squash no longer get a post-merge re-test. That run gated nothing — it\n # reported after main already had the commit — and the next PR, which branches from the\n # merged main, is what actually catches a semantic conflict.\n pull_request:\n\npermissions:\n contents: read\n\nconcurrency:\n group: ci-\\${{ github.ref }}\n cancel-in-progress: true\n`;\n\nconst CHECK_NOTE = ` # ── One job, one install ────────────────────────────────────────────────────\n # ★ WAS 3 SEPARATE JOBS: lint and format, types, tests. They shared an identical\n # prologue — checkout, setup-bun, \\`bun install --frozen-lockfile\\` — and the install,\n # not the check, was the cost: measured on the mini 2026-09-22, install p50 51s /\n # p90 81s against 1-3s for the command the install existed to enable. 3 jobs meant 3\n # installs and 3 containers to compute one answer, and on a 3-slot pool that was one\n # pull request asking for every slot to answer a single question.\n # ⛔ ONE STEP: \\`bun run check\\`, THE REPOSITORY'S OWN GATE, NOT A COPY OF ITS LANES.\n # The house rule is that CI runs the same command a person runs, and a workflow that\n # re-lists \\`lint\\`, \\`types\\`, \\`test\\` is a second copy of that command which can quietly\n # check LESS than the local gate. Measured 2026-09-22: \\`bun run check\\` in homeflare-kit\n # is \\`lint && types && build && test\\`, and its tests/dist.test.ts SKIPS ITSELF when\n # dist/ is absent — so a workflow running lint/types/test without the build would drop\n # that test silently and still report green. homeflare-alerts' check runs\n # \\`check:types\\` and \\`build:web\\`; homeflare-subnet-calc's delegates to \\`verify\\`. All\n # fourteen repositories have a \\`check\\` script and every one is local-only — no network,\n # no deploy. Checked, not assumed.\n # ★ THE SPLIT OF RESPONSIBILITY: this renderer owns the PLUMBING — triggers, permissions,\n # concurrency, runner, action versions, the aggregate gate — and the repository owns\n # WHAT ITS GATE RUNS, in package.json, where a change to it shows up in that\n # repository's own diff rather than in a workflow nobody reads.\n # ⚠️ THE TRADE, STATED: a red X says \\`check\\` rather than naming the lane. \\`bun run check\\`\n # short-circuits on the first failure and its output names the lane, which is the same\n # signal a person gets locally.`;\n\nconst WORKFLOWS_NOTE = ` # ★ \\`workflow lint\\` STAYS ITS OWN JOB on purpose: it needs no \\`bun install\\` at all\n # (actionlint is preloaded on the mini's job image — measured 5s end to end), so\n # folding it into \\`check\\` would make a YAML-only change pay the install for nothing.`;\n\nconst ACTIONLINT_NOTE = ` # ⛔ THERE IS NO FIRST-PARTY actionlint ACTION, and the npm package by that name is\n # an unrelated wasm port with no \\`bin\\`. The vendor's own documented CI path is\n # \\`download-actionlint.bash\\` — but that script does NOT verify a checksum (read at\n # the v${ACTIONLINT_VERSION} tag, 2026-09-16: \\`curl -L \"$url\" | tar xvz\\`, no sha256sum anywhere).\n # rhysd's releases DO publish a \\`_checksums.txt\\` per version; this verifies against\n # that instead of trusting the tarball on receipt.\n # ⚠️ ARCH FROM THE RUNNER, NOT HARD-CODED: the mini's runners are arm64, and an amd64\n # binary fails with \"exec format error\" (the checksum file lists both).`;\n\nconst INSTALL_ACTIONLINT = `set -euo pipefail\nversion=${ACTIONLINT_VERSION}\nif command -v actionlint >/dev/null 2>&1 &&\n [ \"$(actionlint -version 2>/dev/null | sed -n 1p)\" = \"\\${version}\" ]; then\n cp \"$(command -v actionlint)\" ./actionlint\n exit 0\nfi\ncase \"$(uname -m)\" in\n x86_64) arch=amd64 ;;\n aarch64 | arm64) arch=arm64 ;;\n *) echo \"unsupported architecture $(uname -m)\" >&2; exit 1 ;;\nesac\nfile=\"actionlint_\\${version}_linux_\\${arch}.tar.gz\"\nbase=\"https://github.com/rhysd/actionlint/releases/download/v\\${version}\"\ncurl -sSfLO \"\\${base}/\\${file}\"\ncurl -sSfLO \"\\${base}/actionlint_\\${version}_checksums.txt\"\ngrep \" \\${file}\\\\$\" \"actionlint_\\${version}_checksums.txt\" | sha256sum -c -\ntar xzf \"\\${file}\" actionlint`;\n\nconst AGGREGATE_NOTE = ` # ── The one check a branch rule can require ─────────────────────────────────\n # ★ A single required check that depends on all of them. Without this, adding a job\n # means editing the branch ruleset too, and forgetting to means the new job is\n # advisory without anyone noticing.\n # ⛔ FAILS ON ANYTHING OTHER THAN success, INCLUDING skipped. A job result has exactly\n # four values: success, failure, cancelled, skipped. Checking only the first two lets\n # a skipped job — a bad \\`if:\\` condition, a misconfigured dependency, a runner picking\n # up nothing — report this aggregate green with a required job never having run.`;\n\nconst VERIFY = `if [ \"\\${{ contains(needs.*.result, 'failure') }}\" = \"true\" ] || \\\\\n [ \"\\${{ contains(needs.*.result, 'cancelled') }}\" = \"true\" ] || \\\\\n [ \"\\${{ contains(needs.*.result, 'skipped') }}\" = \"true\" ]; then\n echo \"one or more required jobs did not succeed: \\${{ toJSON(needs.*.result) }}\" >&2\n exit 1\nfi`;\n\nconst NODE_NOTE = ` # ⛔ REAL NODE, NOT BUN'S SHIM, AND ONLY WHERE THE GATE NEEDS IT. The mini's job\n # image has no node on PATH (ubuntu-latest always did), so a repository whose own\n # \\`check\\` spawns \\`node\\` — or whose framework demands a Node runtime — fails with\n # \\`Executable not found in $PATH: \"node\"\\` without this. Declared as \\`node:\\` in\n # repo-shape.ts, so it is one input rather than a hand-edited block per repository.\n # ⚠️ NO PACKAGE-MANAGER CACHE: bun does the installing, so priming npm's cache costs\n # time and caches nothing anything here reads.`;\n\nfunction prologue(shape: RepoShape): string {\n const node = nodeMajor(shape);\n const bun: JobStep[] = [\n { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },\n { run: 'bun install --frozen-lockfile' },\n ];\n if (node === undefined) return renderSteps([{ uses: CHECKOUT }, ...bun], 3);\n return [\n renderSteps([{ uses: CHECKOUT }], 3),\n NODE_NOTE,\n renderSteps(\n [{ uses: SETUP_NODE, with: { 'node-version': node, 'package-manager-cache': 'false' } }],\n 3,\n ),\n renderSteps(bun, 3),\n ].join('\\n');\n}\n\nfunction renderExtraJob(job: ExtraJob, shape: RepoShape, on: string): string {\n const needs =\n (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\\n`;\n // ★ A TIMEOUT IS THE JOB'S, NOT THE SHAPE'S. Only a job that starts something with its\n // own wait needs one, and it is rendered where a reader looks for it.\n const timeout = job.timeout === undefined ? '' : ` timeout-minutes: ${job.timeout}\\n`;\n const steps =\n job.bun === false\n ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)\n : [prologue(shape), renderSteps(job.steps, 3)].join('\\n');\n // ★ The stated reason is rendered into the file. A job nobody can explain is a job\n // nobody dares delete, so the explanation travels with it.\n return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}\n ${job.id}:\n name: ${job.name}\n${needs} runs-on: ${on}\n${timeout} steps:\n${steps}\n`;\n}\n\n/** The whole `ci.yml` for a shape. */\nexport function renderCi(shape: RepoShape): string {\n const on = runsOn(shape.runner);\n const extras = shape.extraJobs ?? [];\n const needs = ['check', 'workflows', ...extras.map((job) => job.id)];\n\n return `${HEADER}${runnerNote(shape)}${TRIGGER}\njobs:\n${CHECK_NOTE}\n check:\n name: check\n runs-on: ${on}\n steps:\n${prologue(shape)}\n${renderSteps([{ run: 'bun run check' }], 3)}\n\n${WORKFLOWS_NOTE}\n workflows:\n name: workflow lint\n runs-on: ${on}\n steps:\n${renderSteps([{ uses: CHECKOUT }], 3)}\n${ACTIONLINT_NOTE}\n${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}\n${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}\n\n${extras.map((job) => `${renderExtraJob(job, shape, on)}\\n`).join('')}${AGGREGATE_NOTE}\n ci:\n name: ci\n if: always()\n needs: [${needs.join(', ')}]\n runs-on: ${on}\n steps:\n${renderSteps([{ name: 'Verify every job succeeded', run: VERIFY }], 3)}\n`;\n}\n",
@@ -10,10 +10,11 @@
10
10
  "/**\n * `.github/dependabot.yml`, rendered.\n *\n * ★ MEASURED BEFORE IT WAS RENDERED (2026-09-22): 1 of 14 repositories had a Dependabot\n * config. Twelve took no dependency or Action updates at all, and nothing said so.\n *\n * ⛔ `@homeflare/*` IS IGNORED HERE, NOT GROUPED (retired 2026-09-23, kit auto-bumper\n * design, Tim). A `homeflare` group and a rendered `dependabot-automerge.yml`\n * used to carry kit releases into a consumer; both are gone. `homeflare-bumper` does\n * that job now, over `workflow_dispatch` from this repo's own release, with a schedule\n * backstop — a Dependabot bump running at the same time would open a SECOND, competing\n * pull request for the same version bump. The `ignore` entry below is what stops that:\n * Dependabot never proposes `@homeflare/*` at all, so there is only ever one bump PR.\n *\n * Every Dependabot claim below was read from GitHub's docs or dependabot-core's source on\n * 2026-09-22 (dependabot-core v0.397.0); `docs/repo-shape-dependabot.md` has the citations.\n */\nimport type { RepoShape } from './shape.ts';\n\n/** The first-party scope. Every package the kit publishes is under it. */\nexport const HOMEFLARE_PATTERN = '@homeflare/*';\n\n/**\n * ★ SEVEN DAYS. A third-party release is proposed once it is a week old rather than on\n * Dependabot's undocumented-but-real 3-day default — that is also the supply-chain\n * half, since a compromised release is usually yanked within days.\n */\nexport const THIRD_PARTY_COOLDOWN_DAYS = 7;\n\n/** `.github/dependabot.yml`. */\nexport function renderDependabot(shape: RepoShape): string {\n // ⚠️ `directories`, not `directory`, for bun: a workspace keeps a dependency in the\n // package that declares it, so pointing only at `/` leaves every `packages/*`\n // manifest unwatched — and the symptom is silence, not an error.\n const bunDirs = shape.publishes ? ['/', '/packages/*'] : ['/'];\n return `# Dependabot for ${shape.repository}.\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Refresh with \\`bun run repo-shape:refresh\\`.\n#\n# ⛔ THIS FILE LIVES AT .github/dependabot.yml, NOT IN .github/workflows/. Dependabot is a\n# platform feature, not an Action — a config placed among the workflows is silently\n# ignored, and the symptom is simply that no pull requests ever arrive. Measured\n# 2026-09-22: 13 of 14 HomeFlare repositories had no dependabot config at all, so their\n# Actions and their toolchain went stale invisibly, which is exactly how nothing fails.\n#\n# ★ WHY GROUPED RATHER THAN ONE PR PER DEPENDENCY. The default opens a pull request per\n# outdated package; that is a trickle nobody reviews properly. Each group below is a set\n# that is either safe to take together or needs deciding together.\n#\n# ★ THE BILLING LOCK DOES NOT STOP THIS FILE. Dependabot runs as a GitHub-hosted \"dynamic\"\n# workflow that GitHub's docs say \"does not count towards your included GitHub Actions\n# minutes\"; measured 2026-09-22, a private estate repository's Dependabot job got a hosted\n# runner 86 minutes after that account's ordinary hosted jobs were refused for billing.\n# It cannot use the mini anyway: a self-hosted Dependabot runner must be Linux x64 with\n# Docker, and the mini's runners are arm64 containers with no Docker socket.\nversion: 2\n\nupdates:\n # ── The toolchain (bun.lock) ────────────────────────────────────────────────\n # ⚠️ STILL BLOCKED UPSTREAM, 2026-09-22 — unrelated to the ignore below. bun 1.4 writes\n # bun.lock \\`lockfileVersion\\` 2 and Dependabot's updater bundles bun 1.3.14, which reads\n # up to 1, so this whole block fails in every estate repository with \"Unsupported\n # bun.lock 'lockfileVersion' 2\" before it reads a single manifest. The fix is\n # dependabot/dependabot-core pull request 16071. The github-actions block is unaffected.\n - package-ecosystem: bun\n directories:\n${bunDirs.map((dir) => ` - ${dir}`).join('\\n')}\n schedule:\n interval: weekly\n day: monday\n time: '09:00'\n timezone: America/New_York\n cooldown:\n default-days: ${THIRD_PARTY_COOLDOWN_DAYS}\n # ⛔ NEVER PROPOSED HERE. \\`homeflare-bumper\\` opens the one pull request that bumps\n # \\`@homeflare/*\\` (kit auto-bumper design, Tim 2026-09-23) — a Dependabot\n # update for the same package would race it and, on the weeks they disagree, leave\n # two open pull requests fighting over the same \\`package.json\\` line.\n ignore:\n - dependency-name: '${HOMEFLARE_PATTERN}'\n open-pull-requests-limit: 5\n commit-message:\n prefix: 'chore'\n include: scope\n labels: [dependencies]\n groups:\n # oxfmt and oxlint move together and only affect style. Minor and patch bumps are\n # noise unless they fail CI, which is what CI is for.\n lint-and-format:\n patterns: ['oxfmt', 'oxlint']\n update-types: [minor, patch]\n\n # ⚠️ MAJORS EXCLUDED DELIBERATELY. TypeScript majors change what typechecks;\n # changesets majors have renamed inputs and dropped compatibility (the action's v2\n # did both). These want reading, not merging on green.\n build-tooling:\n patterns: ['typescript', '@changesets/*', '@types/bun']\n update-types: [minor, patch]\n\n # ── The workflows themselves ────────────────────────────────────────────────\n # ★ Actions go stale invisibly: nothing fails, they just keep running old code. The\n # estate's first workflows pinned checkout@v5 (current: v7) and changesets/action@v1\n # (current: v2, with every input renamed) — measured 2026-09-15.\n - package-ecosystem: github-actions\n directory: /\n schedule:\n interval: weekly\n day: monday\n time: '09:00'\n timezone: America/New_York\n open-pull-requests-limit: 5\n commit-message:\n prefix: 'ci'\n labels: [dependencies, github-actions]\n groups:\n actions:\n patterns: ['*']\n update-types: [minor, patch]\n`;\n}\n",
11
11
  "/**\n * `.github/workflows/security.yml`, rendered.\n *\n * ⛔ THIS FILE IS WHY THE RENDERER EXISTS. Measured across 13 repositories on 2026-09-22,\n * every one of these was a per-repo accident rather than a decision:\n * · `push: branches: [main]` still present in 5, removed in 8;\n * · the `concurrency:` block missing entirely in 5 — so a superseded scan ran to\n * completion holding one of the mini's 3 slots for a result nobody would read;\n * · `pull-requests: read` missing in 2, which makes every pull-request scan fail 403\n * before it inspects a single line;\n * · `gitleaks/gitleaks-action` pinned at `@v2` in 5 and `@v3` in 8. v2 declares\n * `runs: node20`; GitHub removed that runtime from its HOSTED runner images on\n * 2026-09-16, so v2 fails on ubuntu-latest and still runs on the mini. Moving to\n * v3 drops that dependency before any job moves back to a hosted runner — it is\n * not repairing a scan that is currently broken;\n * · the weekly cron at three different minutes.\n * One renderer makes all thirteen the same file, and the next such fix is one commit.\n */\nimport type { RepoShape } from './shape.ts';\nimport { runsOn } from './shape.ts';\nimport { renderSteps } from './yaml.ts';\n\n/**\n * ⚠️ Off the hour deliberately — `:00` is when every scheduled workflow on GitHub fires\n * at once, and queue time is the result. One minute for the whole estate: a repository\n * does NOT get its own, because \"which minute is this repo on\" is a question with no\n * useful answer and 13 repos produced 3 different ones by accident.\n */\nconst CRON = '41 6 * * 1';\n\nconst HEADER = `# Secret scanning, on its own schedule and its own check.\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Its input is this repository's \\`repo-shape.ts\\`; refresh with \\`bun run repo-shape:refresh\\`.\n#\n# ★ SEPARATE FROM ci.yml BY CONCERN. A secret scan answers a different question from \"is\n# this code correct\", it runs on a schedule as well as on pull requests (a rule added\n# tomorrow can find a secret committed today), and it needs full history, which the CI\n# jobs deliberately do not fetch.\n`;\n\nconst TRIGGER = `name: security\n\non:\n # ⛔ NO \\`push: branches: [main]\\`, for the reason ci.yml gives: main only moves by\n # squashing a pull request this same workflow already scanned, so the post-merge scan\n # re-read commits it had just cleared. The weekly cron is what covers \"a rule added\n # tomorrow finds a secret committed today\" — and it is the one that fetches full history.\n pull_request:\n schedule:\n - cron: '${CRON}'\n\npermissions:\n contents: read\n # 🔴 gitleaks-action lists pull-request commits through GitHub's API before scanning,\n # and posts inline annotations. Without this, \\`pull_request\\` runs fail 403 before\n # inspecting any code. Two of thirteen repositories were missing it (2026-09-22).\n pull-requests: read\n\n# ⛔ cancel-in-progress IS CONDITIONAL, NOT \\`true\\`. The scheduled run's ref is\n# refs/heads/main; a plain \\`true\\` would let anything sharing that group kill the weekly\n# full-history scan. Only pull-request runs are ever cancelled.\nconcurrency:\n group: security-\\${{ github.ref }}\n cancel-in-progress: \\${{ github.event_name == 'pull_request' }}\n`;\n\nconst FETCH_NOTE = ` with:\n # ⛔ FULL HISTORY. A shallow clone scans only the tip, which misses the case that\n # matters most: a secret added and then removed in a later commit. It is still\n # in the history, still fetchable, and still compromised.\n fetch-depth: 0`;\n\nconst GITLEAKS_NOTE = ` # ⛔ v3, NOT v2. v2 declares \\`runs: node20\\`. GitHub removed that runtime from\n # its HOSTED runner images on 2026-09-16 (gitleaks/gitleaks-action's own v3.0.0\n # migration notes, verified 2026-09-17), so v2 fails on ubuntu-latest and still\n # runs on the mini's self-hosted runner. Moving to v3 drops a dependency on a\n # runtime the platform has already withdrawn, before any job moves back to a\n # hosted runner — it is not repairing a scan that is currently broken. v3\n # changes only the runtime to node24 — inputs, outputs and behaviour are unchanged.\n # ★ arm64-SAFE ON THE MINI, verified rather than assumed: v3's src/gitleaks.js builds\n # its download URL from \\`process.arch\\`, so the runner asks for\n # gitleaks_<version>_linux_arm64.tar.gz, an asset its default release publishes\n # (both read 2026-09-22). Nothing here is hard-coded to x64.\n # ★ No \\`GITLEAKS_LICENSE\\`: that env var is required only for GitHub Organization\n # accounts, and \\`taslabs-net\\` is a personal User account (2026-09-17,\n # \\`gh api users/taslabs-net --jq .type\\` -> \\`User\\`).`;\n\n/** The whole `security.yml` for a shape. */\nexport function renderSecurity(shape: RepoShape): string {\n const on = runsOn(shape.runner);\n // ⚠️ A GitHub ACTIONS EXPRESSION, NOT A TEMPLATE LITERAL. `${{ … }}` is interpolated by\n // the Actions runner at job time and must reach the file verbatim, so it is a regular\n // string on purpose — `no-template-curly-in-string` is warning about exactly the\n // confusion this comment resolves, and it is right to ask.\n // oxlint-disable-next-line no-template-curly-in-string\n const token = '${{ secrets.GITHUB_TOKEN }}';\n const scheduled =\n shape.runner === 'mini'\n ? `\\n # ⛔ THE SCHEDULED RUN GOES TO THE MINI TOO: if the runner daemon is down this queues\\n # rather than failing, so a missed weekly scan is silent. homeflare-mini's\\n # \\`ci-runner\\` vmalert group is what notices that, not this workflow.`\n : '';\n\n return `${HEADER}${TRIGGER}\njobs:\n secrets:\n name: secret scan${scheduled}\n runs-on: ${on}\n steps:\n${renderSteps([{ uses: 'actions/checkout@v7' }], 3)}\n${FETCH_NOTE}\n\n${GITLEAKS_NOTE}\n${renderSteps([{ uses: 'gitleaks/gitleaks-action@v3', env: { GITHUB_TOKEN: token } }], 3)}\n`;\n}\n",
12
12
  "/**\n * `renderRepoShape` — one declaration, both halves of a standard repository.\n *\n * ★ THIS IS THE POINT OF THE WHOLE MODULE. A repository declares its shape once and gets\n * its FILES (here) and its GitHub SETTINGS (through `policy`, which is the options\n * object `@homeflare/alchemy`'s `declareRepoPolicy` takes). Before this, the workflow\n * that produces a status check and the ruleset that requires that status check were\n * written in two files by hand, and nothing connected them: renaming the aggregate job\n * would leave a required check that never reports, and a pull request would wait on it\n * forever with auto-merge armed. `checks` below is derived from the rendered workflows,\n * so the two cannot disagree.\n *\n * ⛔ NO IMPORT CROSSES BETWEEN THE PACKAGES, IN EITHER DIRECTION. `policy` is a plain\n * object that is structurally assignable to `RepoPolicyOptions`; `@homeflare/alchemy`\n * does not depend on `@homeflare/config` and this does not depend on Alchemy or Effect.\n * That matters because `@homeflare/config` is installed in 13 of 14 repositories and\n * `@homeflare/alchemy` in 4 — the drift check has to run everywhere, and it must not\n * drag a provider library into a repository that has no stack.\n * ⚠️ A structural seam is checked only where both sides are present: the assignability\n * test lives in `packages/alchemy/tests/repo-shape-policy.test.ts`, which is the one\n * place that imports both.\n */\nimport { renderCi } from './ci.ts';\nimport { renderActionlintConfig, renderChangesetConfig } from './companions.ts';\nimport { renderDependabot } from './dependabot.ts';\nimport { renderSecurity } from './security.ts';\nimport type { RenderedPath, RepoShape } from './shape.ts';\n\n/** The status-check contexts a rendered repository reports. */\nexport interface RepoShapePolicy {\n readonly owner: string;\n readonly repository: string;\n /**\n * ⛔ EXACTLY THE AGGREGATES, NEVER THE LEAF JOBS. `ci` is `if: always()` and fails when\n * any job it needs did not succeed, so requiring it requires all of them. Requiring a\n * leaf job instead would mean a ruleset edit every time a job is added, and a context\n * that stops reporting leaves every pull request pending rather than failing.\n */\n readonly checks: readonly string[];\n}\n\nexport interface RenderedRepo {\n /** Every rendered file, by its path relative to the repository root. */\n readonly files: Readonly<Record<string, string>>;\n /**\n * The options `declareRepoPolicy(id, { ...rendered.policy, settings })` takes.\n * Squash-only, auto-merge, delete-branch-on-merge and the `main` ruleset come from\n * there; the check names come from here.\n */\n readonly policy: RepoShapePolicy;\n}\n\n/**\n * Render a repository's tooling files and the policy that matches them.\n *\n * const rendered = renderRepoShape(shape);\n * rendered.files['.github/workflows/ci.yml'] // the file\n * rendered.policy.checks // ['ci', 'secret scan']\n *\n * ⚠️ EXCEPTED FILES ARE STILL RENDERED. `files` is what the standard says this repository\n * should have; `drift.ts` is what decides which of them are compared. Keeping them here\n * means `--show` can print the standard version of an excepted file, which is how\n * anyone judges whether the exception is still worth its reason.\n */\nexport function renderRepoShape(shape: RepoShape): RenderedRepo {\n const files: Record<string, string> = {\n '.changeset/config.json': renderChangesetConfig(shape),\n '.github/dependabot.yml': renderDependabot(shape),\n '.github/workflows/ci.yml': renderCi(shape),\n '.github/workflows/security.yml': renderSecurity(shape),\n };\n\n const actionlint = renderActionlintConfig(shape);\n if (actionlint !== undefined) files['.github/actionlint.yaml'] = actionlint;\n\n return {\n files,\n policy: { checks: ['ci', 'secret scan'], owner: shape.owner, repository: shape.repository },\n };\n}\n\n/** Every path this renderer can emit, whatever a given shape asks for. */\nexport const RENDERED_PATHS: readonly RenderedPath[] = [\n '.changeset/config.json',\n '.github/actionlint.yaml',\n '.github/dependabot.yml',\n '.github/workflows/ci.yml',\n '.github/workflows/security.yml',\n];\n",
13
- "/**\n * The drift check: re-render, compare, and say what to run.\n *\n * ★ WHAT MAKES THIS DIFFERENT FROM A LINT RULE. It does not describe good workflows; it\n * asserts that this repository's committed files ARE the rendered ones. A hand edit\n * fails here before it can spread, which is the mechanism — not the convention — that\n * keeps fourteen repositories the same.\n *\n * ⛔ IT REPORTS, IT DOES NOT REPAIR, exactly like `checkProject` in this package. A check\n * that silently rewrote the working tree would erase the evidence of what someone\n * changed, and the one case worth catching — a deliberate edit that should have been an\n * exception — is the case it would erase. `refresh.ts` is the separate, explicit writer.\n *\n * ⚠️ EVERY COMPARISON IS EXACT TEXT, NOT STRUCTURE. `checkProject` compares parsed values\n * because a project may reformat its copy of a preset. Here the file IS generated, so\n * any difference at all — including a reflowed comment — means the file did not come\n * out of the renderer, and reformatting it would defeat the point of generating it.\n * The one exception is line endings, normalised so a CRLF checkout is not \"drift\".\n */\nimport { type RenderedRepo, renderRepoShape } from './render.ts';\nimport type { RenderedPath, RepoShape, RepoShapeException } from './shape.ts';\n\n/** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */\nexport type Problem = string;\n\n/** The command that makes a difference go away. Printed with every drift problem. */\nexport const REFRESH_COMMAND = 'bun run repo-shape:refresh';\n\nfunction normalize(text: string): string {\n return text.replaceAll('\\r\\n', '\\n');\n}\n\nasync function readIfPresent(path: string): Promise<string | undefined> {\n const file = Bun.file(path);\n return (await file.exists()) ? normalize(await file.text()) : undefined;\n}\n\n/**\n * ⛔ AN EXCEPTION FOR A FILE THE SHAPE DOES NOT RENDER IS ITSELF A PROBLEM. Otherwise a\n * repository that moved from the mini to GitHub-hosted runners would keep a stale\n * `.github/actionlint.yaml` exception forever, and the exception list — the one place\n * that is supposed to hold every deviation — would be lying about one of them.\n */\nfunction staleExceptions(\n rendered: RenderedRepo,\n exceptions: readonly RepoShapeException[],\n): Problem[] {\n return exceptions\n .filter((exception) => rendered.files[exception.file] === undefined)\n .map(\n (exception) =>\n `${exception.file}: excepted (since ${exception.since}) but this shape renders no such file — drop the exception`,\n );\n}\n\n/**\n * ⚠️ A DUPLICATE EXCEPTION HIDES A REASON. Two `except()` entries for one file means one\n * of the two reasons is never read, and the one that loses is arbitrary. Refuse both.\n */\nfunction duplicateExceptions(exceptions: readonly RepoShapeException[]): Problem[] {\n const seen = new Set<RenderedPath>();\n const duplicated = new Set<RenderedPath>();\n for (const exception of exceptions) {\n if (seen.has(exception.file)) duplicated.add(exception.file);\n seen.add(exception.file);\n }\n return [...duplicated].map(\n (file) => `${file}: declared as an exception more than once — keep one, with one reason`,\n );\n}\n\nexport interface DriftReport {\n /** Empty when the committed files are the rendered ones. */\n readonly problems: readonly Problem[];\n /** Paths whose committed text differs from the render, excluding excepted files. */\n readonly drifted: readonly string[];\n /** Paths the repository declared it owns, with the reason it gave. */\n readonly excepted: readonly RepoShapeException[];\n}\n\n/**\n * Compare a repository's committed tooling files against its declared shape.\n *\n * const report = await driftInRepoShape(process.cwd(), shape);\n * expect(report.problems).toEqual([]);\n *\n * A declared exception passes. An undeclared difference fails, and so does a missing file.\n */\nexport async function driftInRepoShape(projectDir: string, shape: RepoShape): Promise<DriftReport> {\n const rendered = renderRepoShape(shape);\n const exceptions = shape.exceptions ?? [];\n const excepted = new Map(exceptions.map((exception) => [exception.file as string, exception]));\n const problems: Problem[] = [\n ...duplicateExceptions(exceptions),\n ...staleExceptions(rendered, exceptions),\n ];\n const drifted: string[] = [];\n\n for (const [path, expected] of Object.entries(rendered.files)) {\n if (excepted.has(path)) continue;\n const actual = await readIfPresent(`${projectDir}/${path}`);\n if (actual === undefined) {\n problems.push(`${path}: missing — run \\`${REFRESH_COMMAND}\\``);\n drifted.push(path);\n continue;\n }\n if (actual !== normalize(expected)) {\n drifted.push(path);\n problems.push(\n `${path}: differs from what @homeflare/config renders for this shape. ` +\n `Run \\`${REFRESH_COMMAND}\\` to take the standard, ` +\n `or declare it with except({ file: '${path}', reason: '…', since: '…' }) in repo-shape.ts.`,\n );\n }\n }\n\n return { drifted, excepted: exceptions, problems };\n}\n\n/**\n * Every rendered path that is NOT compared, with why. For a report, not for a gate.\n * ★ Printed by `refresh --check` so a passing run still names what it did not check.\n * An exception that stops being visible is an exception that stops being reconsidered.\n */\nexport function exceptionSummary(shape: RepoShape): readonly string[] {\n return (shape.exceptions ?? []).map(\n (exception) =>\n `${exception.file}: not checked — ${exception.reason} (since ${exception.since})`,\n );\n}\n",
14
- "/**\n * The writer, and the CLI a repository wires to `repo-shape:refresh`.\n *\n * ⛔ SEPARATE FROM THE CHECK, ON PURPOSE. `drift.ts` never writes and this never gates;\n * a tool that did both would repair the working tree during CI and report green on a\n * repository whose committed files are still wrong.\n *\n * ★ IT ONLY WRITES WHAT CHANGED. An unchanged file keeps its mtime, so a refresh in a\n * watch-mode session does not restart anything, and `git status` after a no-op refresh\n * is empty rather than \"14 files touched\".\n *\n * ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns\n * would overwrite the deviation the reason was written for — the one destructive thing\n * this could do, and the one it must not.\n */\nimport { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';\nimport { renderRepoShape } from './render.ts';\nimport { type RepoShape, isExcepted } from './shape.ts';\nimport type { RenderedPath } from './shape.ts';\n\nexport interface RefreshResult {\n /** Paths written because their content changed or they were absent. */\n readonly written: readonly string[];\n /** Paths already correct. */\n readonly unchanged: readonly string[];\n /** Paths skipped because the shape declares an exception for them. */\n readonly skipped: readonly string[];\n}\n\n/** Write a repository's rendered files into `projectDir`. */\nexport async function refreshRepoShape(\n projectDir: string,\n shape: RepoShape,\n): Promise<RefreshResult> {\n const rendered = renderRepoShape(shape);\n const written: string[] = [];\n const unchanged: string[] = [];\n const skipped: string[] = [];\n\n for (const [path, contents] of Object.entries(rendered.files)) {\n if (isExcepted(shape, path as RenderedPath)) {\n skipped.push(path);\n continue;\n }\n const target = `${projectDir}/${path}`;\n const file = Bun.file(target);\n if ((await file.exists()) && (await file.text()) === contents) {\n unchanged.push(path);\n continue;\n }\n await Bun.write(target, contents);\n written.push(path);\n }\n\n return { skipped, unchanged, written };\n}\n\n/**\n * ★ `Bun.write(Bun.stdout, …)` RATHER THAN `console.log`. Two reasons, and the lint rule\n * is the lesser: a CLI's output is a stream it owns, and writing to it explicitly is\n * what lets `repoShapeCli` be called in a test and in a `repo-shape.ts` without either\n * one inheriting a global. `console` in `src/` is also off-limits in the house preset —\n * allowed under `tests/**` and `scripts/**`, and this is neither.\n */\nasync function say(\n stream: typeof Bun.stdout | typeof Bun.stderr,\n lines: readonly string[],\n): Promise<void> {\n if (lines.length === 0) return;\n await Bun.write(stream, `${lines.join('\\n')}\\n`);\n}\n\n/**\n * The `repo-shape:refresh` entry point. `--check` reports drift and exits non-zero\n * instead of writing, which is what a repository puts in CI when it does not want the\n * check inside `bun test`.\n */\nexport async function repoShapeCli(\n projectDir: string,\n shape: RepoShape,\n argv: readonly string[],\n): Promise<number> {\n if (argv.includes('--check')) {\n const report = await driftInRepoShape(projectDir, shape);\n // ★ The exceptions are printed on a PASSING run too. An exception that stops being\n // visible is an exception that stops being reconsidered.\n await say(\n Bun.stdout,\n exceptionSummary(shape).map((note) => `· ${note}`),\n );\n if (report.problems.length === 0) {\n const count = report.excepted.length;\n await say(Bun.stdout, [\n `repo shape: in step with @homeflare/config (${count} declared exception(s))`,\n ]);\n return 0;\n }\n await say(Bun.stderr, [\n ...report.problems.map((problem) => `✗ ${problem}`),\n '',\n `Run \\`${REFRESH_COMMAND}\\`, or declare the deviation with a reason.`,\n ]);\n return 1;\n }\n\n const result = await refreshRepoShape(projectDir, shape);\n await say(Bun.stdout, [\n ...result.written.map((path) => `wrote ${path}`),\n ...result.unchanged.map((path) => `ok ${path}`),\n ...result.skipped.map((path) => `excepted ${path}`),\n ]);\n return 0;\n}\n"
13
+ "/**\n * Rendered paths this package stopped rendering — the \"no fossils\" list.\n *\n * ★ WHY THIS EXISTS. `renderRepoShape` only emits the files a shape asks for today; it\n * never says what it used to emit, and `refreshRepoShape` only writes what it renders —\n * so a file retired from the renderer was never deleted by a refresh. It just stopped\n * being updated, silently, forever, in every repository that had already taken it.\n *\n * MEASURED 2026-09-24: `@homeflare/config` 0.12.0 (kit PR 199) stopped rendering\n * `.github/workflows/dependabot-automerge.yml`. Every consumer that had refreshed to\n * 0.12.0 before this module existed (homeflare-wiki bump PR 19, homeflare-mini bump\n * PR 47) kept the dead file, and `driftInRepoShape` never said so — it only compares\n * paths the CURRENT shape renders, and a retired path is not one of those.\n *\n * ⛔ RETIRING A PATH IS A ONE-WAY DOOR, NOT A RENAME. Once a path is here it can never\n * become a `RenderedPath` again: `render.ts` would start writing a file this module is\n * also trying to delete, and every refresh would fight itself. Give the replacement a\n * new path instead. `repo-shape-retired.test.ts` asserts the two lists never overlap.\n *\n * ⚠️ DELETION IS GATED ON PROOF, NOT ON THE PATH ALONE. A repository can have a\n * hand-written file sitting at the exact path a retired renderer used to own — a fork of\n * the old rendered file, kept on purpose, or unrelated content that just landed there.\n * `wasRenderedByUs` is the proof: every renderer in this directory writes the\n * `🤖 RENDERED BY @homeflare/config` line into its header (grep the directory for it),\n * and that line is the one thing a hand-written file has no reason to contain.\n * `refreshRepoShape` deletes a retired path only when the line is present; otherwise it\n * refuses and reports the path, the same way it refuses to overwrite an excepted file.\n */\nimport { requireSemver, requireSentence } from './guards.ts';\n\n/**\n * A path `@homeflare/config` no longer renders, but may still find committed in a\n * repository that refreshed before the retirement. Extend this union at the same time a\n * path is added to `RETIRED_FILES` below — never reuse one already there.\n */\nexport type RetiredPath = '.github/workflows/dependabot-automerge.yml';\n\nexport interface RetiredFile {\n /** The path this package rendered, once. */\n readonly path: RetiredPath;\n /** The `@homeflare/config` version whose release stopped rendering it. */\n readonly retiredIn: string;\n /** Why, and which kit change did it — written at the retirement, like `except()`'s reason. */\n readonly reason: string;\n}\n\nfunction retire(file: RetiredFile): RetiredFile {\n return {\n path: file.path,\n reason: requireSentence('retired reason', file.reason),\n retiredIn: requireSemver(file.retiredIn),\n };\n}\n\n/** Every path `@homeflare/config` used to render, oldest retirement first. */\nexport const RETIRED_FILES: readonly RetiredFile[] = [\n retire({\n path: '.github/workflows/dependabot-automerge.yml',\n reason:\n 'kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper ' +\n \"carries a kit release into each consumer now, over the kit's own release workflow\",\n retiredIn: '0.12.0',\n }),\n];\n\n/**\n * The substring every generated file's header has carried since this package's first\n * renderer. Kept independent of the four current renderers' own copies of this text\n * (`ci.ts`, `security.ts`, `dependabot.ts`, `companions.ts`) on purpose: a future wording\n * change to the live header must not stop this module recognising a file rendered under\n * the old one.\n */\nexport const GENERATED_FILE_MARKER = '🤖 RENDERED BY @homeflare/config';\n\n/**\n * Whether `content` is provably a file this package once wrote.\n *\n * ⚠️ THE MARKER HAS TO START A LINE OF ITS OWN, NOT MERELY OCCUR SOMEWHERE IN THE TEXT.\n * Every real header carries it as a whole comment line — `# 🤖 RENDERED BY\n * @homeflare/config — DO NOT EDIT…`, third line in every renderer in this directory, so\n * this does not require it to be the FIRST line — but a bare `content.includes(...)`\n * would also match a hand-written file that merely *talks about* the marker, e.g. a\n * comment reading \"this file used to be 🤖 RENDERED BY @homeflare/config before it was\n * retired; keeping it by hand now\" — adversarial review, 2026-09-24, caught this exact\n * case before it shipped. Requiring the marker at the start of a trimmed line is what a\n * sentence built around it, rather than a header line consisting of it, cannot satisfy.\n * `repo-shape-retired.test.ts` asserts the mid-sentence form stays refused.\n *\n * ⚠️ STILL A PREFIX CHECK ON THAT LINE, NOT A FULL-LINE EXACT MATCH. The retired renderer\n * varied its trailing header prose by `shape.runner` and by repository name, and a\n * future wording change to what follows the marker on a live renderer's header must not\n * stop this recognising a file rendered under the old wording — only the marker itself,\n * `🤖 RENDERED BY @homeflare/config`, has been constant across every renderer this\n * package has ever shipped.\n */\nexport function wasRenderedByUs(content: string): boolean {\n return content\n .split('\\n')\n .some((line) => line.trimStart().startsWith(`# ${GENERATED_FILE_MARKER}`));\n}\n\n/**\n * The message `driftInRepoShape` reports for a retired path that is still present, and\n * `repoShapeCli --check` prints verbatim. Names the fix command either way — whether that\n * command can actually remove the file depends on `wasRenderedByUs`, which only\n * `refreshRepoShape` (holding the file's contents) can decide.\n */\nexport function retiredFileProblem(file: RetiredFile): string {\n return (\n `${file.path}: retired in @homeflare/config@${file.retiredIn} (${file.reason}) ` +\n 'but still present — run `bun run repo-shape:refresh` to remove it'\n );\n}\n",
14
+ "/**\n * The drift check: re-render, compare, and say what to run.\n *\n * ★ WHAT MAKES THIS DIFFERENT FROM A LINT RULE. It does not describe good workflows; it\n * asserts that this repository's committed files ARE the rendered ones. A hand edit\n * fails here before it can spread, which is the mechanism — not the convention — that\n * keeps fourteen repositories the same.\n *\n * ⛔ IT REPORTS, IT DOES NOT REPAIR, exactly like `checkProject` in this package. A check\n * that silently rewrote the working tree would erase the evidence of what someone\n * changed, and the one case worth catching — a deliberate edit that should have been an\n * exception — is the case it would erase. `refresh.ts` is the separate, explicit writer.\n *\n * ⚠️ EVERY COMPARISON IS EXACT TEXT, NOT STRUCTURE. `checkProject` compares parsed values\n * because a project may reformat its copy of a preset. Here the file IS generated, so\n * any difference at all — including a reflowed comment — means the file did not come\n * out of the renderer, and reformatting it would defeat the point of generating it.\n * The one exception is line endings, normalised so a CRLF checkout is not \"drift\".\n */\nimport { type RenderedRepo, renderRepoShape } from './render.ts';\nimport { RETIRED_FILES, retiredFileProblem } from './retired.ts';\nimport type { RenderedPath, RepoShape, RepoShapeException } from './shape.ts';\n\n/** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */\nexport type Problem = string;\n\n/** The command that makes a difference go away. Printed with every drift problem. */\nexport const REFRESH_COMMAND = 'bun run repo-shape:refresh';\n\nfunction normalize(text: string): string {\n return text.replaceAll('\\r\\n', '\\n');\n}\n\nasync function readIfPresent(path: string): Promise<string | undefined> {\n const file = Bun.file(path);\n return (await file.exists()) ? normalize(await file.text()) : undefined;\n}\n\n/**\n * ⛔ AN EXCEPTION FOR A FILE THE SHAPE DOES NOT RENDER IS ITSELF A PROBLEM. Otherwise a\n * repository that moved from the mini to GitHub-hosted runners would keep a stale\n * `.github/actionlint.yaml` exception forever, and the exception list — the one place\n * that is supposed to hold every deviation — would be lying about one of them.\n */\nfunction staleExceptions(\n rendered: RenderedRepo,\n exceptions: readonly RepoShapeException[],\n): Problem[] {\n return exceptions\n .filter((exception) => rendered.files[exception.file] === undefined)\n .map(\n (exception) =>\n `${exception.file}: excepted (since ${exception.since}) but this shape renders no such file — drop the exception`,\n );\n}\n\n/**\n * ⚠️ A DUPLICATE EXCEPTION HIDES A REASON. Two `except()` entries for one file means one\n * of the two reasons is never read, and the one that loses is arbitrary. Refuse both.\n */\nfunction duplicateExceptions(exceptions: readonly RepoShapeException[]): Problem[] {\n const seen = new Set<RenderedPath>();\n const duplicated = new Set<RenderedPath>();\n for (const exception of exceptions) {\n if (seen.has(exception.file)) duplicated.add(exception.file);\n seen.add(exception.file);\n }\n return [...duplicated].map(\n (file) => `${file}: declared as an exception more than once — keep one, with one reason`,\n );\n}\n\n/**\n * ★ A RETIRED PATH IS REPORTED WHETHER OR NOT IT IS PROVABLY OURS. This check only reads\n * the file to see if it is THERE — `wasRenderedByUs` is `refreshRepoShape`'s call to\n * make, because only the writer should decide whether to delete. Reporting here is what\n * makes a fossil visible even in a repository that only ever runs `--check` in CI.\n */\nasync function retiredFilesPresent(projectDir: string): Promise<Problem[]> {\n const problems: Problem[] = [];\n for (const file of RETIRED_FILES) {\n if ((await readIfPresent(`${projectDir}/${file.path}`)) !== undefined) {\n problems.push(retiredFileProblem(file));\n }\n }\n return problems;\n}\n\nexport interface DriftReport {\n /** Empty when the committed files are the rendered ones and no retired path lingers. */\n readonly problems: readonly Problem[];\n /** Paths whose committed text differs from the render, excluding excepted files. */\n readonly drifted: readonly string[];\n /** Paths the repository declared it owns, with the reason it gave. */\n readonly excepted: readonly RepoShapeException[];\n}\n\n/**\n * Compare a repository's committed tooling files against its declared shape.\n *\n * const report = await driftInRepoShape(process.cwd(), shape);\n * expect(report.problems).toEqual([]);\n *\n * A declared exception passes. An undeclared difference fails, and so does a missing file.\n */\nexport async function driftInRepoShape(projectDir: string, shape: RepoShape): Promise<DriftReport> {\n const rendered = renderRepoShape(shape);\n const exceptions = shape.exceptions ?? [];\n const excepted = new Map(exceptions.map((exception) => [exception.file as string, exception]));\n const problems: Problem[] = [\n ...duplicateExceptions(exceptions),\n ...staleExceptions(rendered, exceptions),\n ...(await retiredFilesPresent(projectDir)),\n ];\n const drifted: string[] = [];\n\n for (const [path, expected] of Object.entries(rendered.files)) {\n if (excepted.has(path)) continue;\n const actual = await readIfPresent(`${projectDir}/${path}`);\n if (actual === undefined) {\n problems.push(`${path}: missing — run \\`${REFRESH_COMMAND}\\``);\n drifted.push(path);\n continue;\n }\n if (actual !== normalize(expected)) {\n drifted.push(path);\n problems.push(\n `${path}: differs from what @homeflare/config renders for this shape. ` +\n `Run \\`${REFRESH_COMMAND}\\` to take the standard, ` +\n `or declare it with except({ file: '${path}', reason: '…', since: '…' }) in repo-shape.ts.`,\n );\n }\n }\n\n return { drifted, excepted: exceptions, problems };\n}\n\n/**\n * Every rendered path that is NOT compared, with why. For a report, not for a gate.\n * ★ Printed by `refresh --check` so a passing run still names what it did not check.\n * An exception that stops being visible is an exception that stops being reconsidered.\n */\nexport function exceptionSummary(shape: RepoShape): readonly string[] {\n return (shape.exceptions ?? []).map(\n (exception) =>\n `${exception.file}: not checked — ${exception.reason} (since ${exception.since})`,\n );\n}\n",
15
+ "/**\n * The writer, and the CLI a repository wires to `repo-shape:refresh`.\n *\n * ⛔ SEPARATE FROM THE CHECK, ON PURPOSE. `drift.ts` never writes and this never gates;\n * a tool that did both would repair the working tree during CI and report green on a\n * repository whose committed files are still wrong.\n *\n * ★ IT ONLY WRITES WHAT CHANGED. An unchanged file keeps its mtime, so a refresh in a\n * watch-mode session does not restart anything, and `git status` after a no-op refresh\n * is empty rather than \"14 files touched\".\n *\n * ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns\n * would overwrite the deviation the reason was written for — the one destructive thing\n * this could do, and the one it must not.\n *\n * ⚠️ IT DELETES A RETIRED FILE ONLY WHEN IT CAN PROVE IT RENDERED IT. `RETIRED_FILES`\n * (`retired.ts`) names every path this package used to render; `wasRenderedByUs` is the\n * proof — the file still carries the generated-file header. A retired path present\n * without that header is left alone and reported as refused, on the same reasoning as\n * never overwriting an excepted file: this writer only ever removes what it is sure is\n * its own.\n */\nimport { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';\nimport { renderRepoShape } from './render.ts';\nimport { RETIRED_FILES, wasRenderedByUs } from './retired.ts';\nimport { type RepoShape, isExcepted } from './shape.ts';\nimport type { RenderedPath } from './shape.ts';\n\nexport interface RefreshResult {\n /** Paths written because their content changed or they were absent. */\n readonly written: readonly string[];\n /** Paths already correct. */\n readonly unchanged: readonly string[];\n /** Paths skipped because the shape declares an exception for them. */\n readonly skipped: readonly string[];\n /** Retired paths deleted because they still carried the generated-file header. */\n readonly removed: readonly string[];\n /** Retired paths left alone because they did not — provably hand-written, not ours. */\n readonly refused: readonly string[];\n}\n\n/** Write a repository's rendered files into `projectDir`, and clear its rendered fossils. */\nexport async function refreshRepoShape(\n projectDir: string,\n shape: RepoShape,\n): Promise<RefreshResult> {\n const rendered = renderRepoShape(shape);\n const written: string[] = [];\n const unchanged: string[] = [];\n const skipped: string[] = [];\n\n for (const [path, contents] of Object.entries(rendered.files)) {\n if (isExcepted(shape, path as RenderedPath)) {\n skipped.push(path);\n continue;\n }\n const target = `${projectDir}/${path}`;\n const file = Bun.file(target);\n if ((await file.exists()) && (await file.text()) === contents) {\n unchanged.push(path);\n continue;\n }\n await Bun.write(target, contents);\n written.push(path);\n }\n\n const { refused, removed } = await clearRetiredFiles(projectDir);\n\n return { refused, removed, skipped, unchanged, written };\n}\n\n/**\n * Delete every retired path this package can prove it rendered; leave every other one, and\n * say so. A path absent from `projectDir` is neither removed nor refused — there is\n * nothing there to have an opinion about.\n */\nasync function clearRetiredFiles(\n projectDir: string,\n): Promise<{ removed: string[]; refused: string[] }> {\n const removed: string[] = [];\n const refused: string[] = [];\n\n for (const retiredFile of RETIRED_FILES) {\n const target = `${projectDir}/${retiredFile.path}`;\n const file = Bun.file(target);\n if (!(await file.exists())) continue;\n\n if (wasRenderedByUs(await file.text())) {\n await file.delete();\n removed.push(retiredFile.path);\n } else {\n refused.push(retiredFile.path);\n }\n }\n\n return { refused, removed };\n}\n\n/**\n * ★ `Bun.write(Bun.stdout, …)` RATHER THAN `console.log`. Two reasons, and the lint rule\n * is the lesser: a CLI's output is a stream it owns, and writing to it explicitly is\n * what lets `repoShapeCli` be called in a test and in a `repo-shape.ts` without either\n * one inheriting a global. `console` in `src/` is also off-limits in the house preset —\n * allowed under `tests/**` and `scripts/**`, and this is neither.\n */\nasync function say(\n stream: typeof Bun.stdout | typeof Bun.stderr,\n lines: readonly string[],\n): Promise<void> {\n if (lines.length === 0) return;\n await Bun.write(stream, `${lines.join('\\n')}\\n`);\n}\n\n/**\n * The `repo-shape:refresh` entry point. `--check` reports drift and exits non-zero\n * instead of writing, which is what a repository puts in CI when it does not want the\n * check inside `bun test`.\n */\nexport async function repoShapeCli(\n projectDir: string,\n shape: RepoShape,\n argv: readonly string[],\n): Promise<number> {\n if (argv.includes('--check')) {\n const report = await driftInRepoShape(projectDir, shape);\n // ★ The exceptions are printed on a PASSING run too. An exception that stops being\n // visible is an exception that stops being reconsidered.\n await say(\n Bun.stdout,\n exceptionSummary(shape).map((note) => `· ${note}`),\n );\n if (report.problems.length === 0) {\n const count = report.excepted.length;\n await say(Bun.stdout, [\n `repo shape: in step with @homeflare/config (${count} declared exception(s))`,\n ]);\n return 0;\n }\n await say(Bun.stderr, [\n ...report.problems.map((problem) => `✗ ${problem}`),\n '',\n `Run \\`${REFRESH_COMMAND}\\`, or declare the deviation with a reason.`,\n ]);\n return 1;\n }\n\n const result = await refreshRepoShape(projectDir, shape);\n await say(Bun.stdout, [\n ...result.written.map((path) => `wrote ${path}`),\n ...result.unchanged.map((path) => `ok ${path}`),\n ...result.skipped.map((path) => `excepted ${path}`),\n ...result.removed.map((path) => `removed ${path} (retired; carried our header)`),\n ]);\n // ★ ON ITS OWN LINE, TO STDERR: a refused retired file is the one outcome here that\n // still needs a person. `--check` (above) keeps failing on it — it reports any retired\n // path that is present, proof or not — so this is not the only place it is said, but\n // it is the only place that says WHY refresh did not just fix it.\n await say(\n Bun.stderr,\n result.refused.map(\n (path) =>\n `refused ${path} — present but not provably ours; not deleted. See docs/repo-shape-retired.md.`,\n ),\n );\n return 0;\n}\n"
15
16
  ],
16
- "mappings": "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",
17
- "debugId": "27828123E35E339964756E2164756E21",
17
+ "mappings": "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",
18
+ "debugId": "B40D338DD6A7FD1664756E2164756E21",
18
19
  "names": []
19
20
  }
package/dist/versions.js CHANGED
@@ -20,6 +20,12 @@ function requireIsoDate(value) {
20
20
  }
21
21
  return value;
22
22
  }
23
+ function requireSemver(value) {
24
+ if (!/^\d+\.\d+\.\d+$/.test(value)) {
25
+ throw new Error(`repo-shape: retiredIn must be a released x.y.z, got ${JSON.stringify(value)}`);
26
+ }
27
+ return value;
28
+ }
23
29
  function requireMajor(value) {
24
30
  if (!Number.isInteger(value) || value <= 0) {
25
31
  throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);
@@ -336,5 +342,5 @@ export {
336
342
  ESTATE_VERSIONS
337
343
  };
338
344
 
339
- //# debugId=613673276BC65A5864756E2164756E21
345
+ //# debugId=3E8EB110EB4CEAA964756E2164756E21
340
346
  //# sourceMappingURL=versions.js.map
@@ -2,13 +2,13 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/repo-shape/guards.ts", "../src/repo-shape/shape.ts", "../src/repo-shape/yaml.ts", "../src/repo-shape/ci.ts", "../src/versions.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * The runtime guards `repo-shape` needs and its types cannot express.\n *\n * ★ EXTRACTED FROM `shape.ts`, NOT INVENTED HERE. The types carry one half of the\n * contract — `Stated<R>` refuses an empty or computed reason at compile time — and\n * these carry the other half: the cases a literal type still lets through. Keeping\n * them in one file is what lets `shape.ts` stay the declaration and nothing else.\n *\n * ⚠️ EVERY MESSAGE STARTS `repo-shape:` AND NAMES THE FIELD. These throw at render or at\n * declaration, far from the workflow file they would otherwise break at job time, so\n * the message is the only thing pointing back at the call that caused it.\n */\n\nconst SENTENCE = 12;\n\nexport function requireNonEmpty(field: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);\n return trimmed;\n}\n\nexport function requireSentence(field: string, value: string): string {\n const trimmed = value.trim();\n // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`\n // lets it through and only this does not. Type and guard cover different halves.\n if (trimmed.length < SENTENCE) {\n throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);\n }\n return trimmed;\n}\n\nexport function requireIsoDate(value: string): string {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\n/**\n * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`\n * and resolves the newest 24.x; a fractional or negative value renders YAML the action\n * accepts and then fails to resolve, mid-job, on the runner.\n */\nexport function requireMajor(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);\n }\n return value;\n}\n\n/** `timeout-minutes` must be a positive whole number of minutes. */\nexport function requireMinutes(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: timeout must be a positive whole minute count, got ${value}`);\n }\n return value;\n}\n\nexport function requireJobId(value: string): string {\n // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow\n // GitHub rejects at parse time, which reports as \"workflow file issue\" with no line.\n if (!/^[a-z][a-z0-9_-]*$/.test(value)) {\n throw new Error(\n `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,\n );\n }\n return value;\n}\n",
5
+ "/**\n * The runtime guards `repo-shape` needs and its types cannot express.\n *\n * ★ EXTRACTED FROM `shape.ts`, NOT INVENTED HERE. The types carry one half of the\n * contract — `Stated<R>` refuses an empty or computed reason at compile time — and\n * these carry the other half: the cases a literal type still lets through. Keeping\n * them in one file is what lets `shape.ts` stay the declaration and nothing else.\n *\n * ⚠️ EVERY MESSAGE STARTS `repo-shape:` AND NAMES THE FIELD. These throw at render or at\n * declaration, far from the workflow file they would otherwise break at job time, so\n * the message is the only thing pointing back at the call that caused it.\n */\n\nconst SENTENCE = 12;\n\nexport function requireNonEmpty(field: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed.length === 0) throw new Error(`repo-shape: ${field} must not be blank`);\n return trimmed;\n}\n\nexport function requireSentence(field: string, value: string): string {\n const trimmed = value.trim();\n // ⚠️ THE TYPE CANNOT CATCH `reason: ' '`. `' '` is a non-empty literal, so `Stated`\n // lets it through and only this does not. Type and guard cover different halves.\n if (trimmed.length < SENTENCE) {\n throw new Error(`repo-shape: ${field} must be a sentence, got ${JSON.stringify(value)}`);\n }\n return trimmed;\n}\n\nexport function requireIsoDate(value: string): string {\n if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) {\n throw new Error(`repo-shape: since must be YYYY-MM-DD, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\n/**\n * A retired file names the `@homeflare/config` release that stopped rendering it, so\n * anyone reading `retired.ts` can find the changeset that made the call.\n */\nexport function requireSemver(value: string): string {\n if (!/^\\d+\\.\\d+\\.\\d+$/.test(value)) {\n throw new Error(`repo-shape: retiredIn must be a released x.y.z, got ${JSON.stringify(value)}`);\n }\n return value;\n}\n\n/**\n * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`\n * and resolves the newest 24.x; a fractional or negative value renders YAML the action\n * accepts and then fails to resolve, mid-job, on the runner.\n */\nexport function requireMajor(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: node must be a positive integer major, got ${value}`);\n }\n return value;\n}\n\n/** `timeout-minutes` must be a positive whole number of minutes. */\nexport function requireMinutes(value: number): number {\n if (!Number.isInteger(value) || value <= 0) {\n throw new Error(`repo-shape: timeout must be a positive whole minute count, got ${value}`);\n }\n return value;\n}\n\nexport function requireJobId(value: string): string {\n // ⛔ The id becomes a YAML key and a `needs:` entry. Anything else renders a workflow\n // GitHub rejects at parse time, which reports as \"workflow file issue\" with no line.\n if (!/^[a-z][a-z0-9_-]*$/.test(value)) {\n throw new Error(\n `repo-shape: job id must match /^[a-z][a-z0-9_-]*$/, got ${JSON.stringify(value)}`,\n );\n }\n return value;\n}\n",
6
6
  "/**\n * `RepoShape` — one HomeFlare repository's tooling, as a value.\n *\n * ★ WHY THIS TYPE EXISTS. Measured across the estate on 2026-09-22: 13 repositories each\n * carry a hand-written `.github/workflows/ci.yml`, `security.yml`, `.github/actionlint.yaml`\n * and `.changeset/config.json`. The changeset configs were byte-identical apart from the\n * repository name in 11 of 13. The security workflows were not: five still carried\n * `push: branches: [main]` that the other eight had removed, five were missing the\n * `concurrency:` block entirely, two were missing `pull-requests: read` (whose absence\n * makes every pull-request scan fail 403), the gitleaks action was pinned at `@v2` in\n * five and `@v3` in eight, and the weekly cron minute took three different values. Not\n * one of those differences was a decision. They are what happens when the same edit is\n * applied by hand thirteen times.\n *\n * ⛔ SO: A DIFFERENCE IS EITHER AN INPUT OR AN EXCEPTION. Nothing else. A repository that\n * needs something the standard does not give it either widens this type — and every\n * repository gets the widening — or declares an exception with a stated reason. There\n * is no third option where a file is quietly edited, because `drift.ts` fails on it.\n *\n * ⚠️ THE REASON IS ENFORCED BY THE COMPILER, NOT BY REVIEW. `except()` and `extraJob()`\n * below refuse an empty reason and refuse a reason read out of a variable, so the text\n * has to be written at the exception. See `Stated`.\n */\n\nimport {\n requireIsoDate,\n requireJobId,\n requireMajor,\n requireMinutes,\n requireNonEmpty,\n requireSentence,\n} from './guards.ts';\n\n/** Where a repository's jobs run. */\nexport type RepoRunner =\n /**\n * `[self-hosted, homeflare-mini]` — the interim Linux arm64 runner on the Mac mini.\n * ⚠️ GitHub-hosted runners are refused for this account (billing lock, 2026-09-22).\n */\n | 'mini'\n /** `ubuntu-latest`. Public repositories, where GitHub-hosted minutes are free. */\n | 'github';\n\n/** One step in a rendered job. Either a `uses:` or a `run:`, never both. */\nexport type JobStep = {\n readonly name?: string;\n /**\n * The step's `if:` condition, verbatim.\n *\n * ★ WIDENED RATHER THAN EXCEPTED, 2026-09-22. homeflare-kit's `consumer smoke test`\n * ends with a step that renders the packed tarball sizes onto the run summary, and\n * it carries `if: always()` on purpose — a FAILED smoke test is exactly when the\n * sizes are worth reading. Without this field kit had two options and both were\n * worse: drop the condition, losing the summary on the only runs that need it, or\n * `except('.github/workflows/ci.yml')`, which buys one repository its file back and\n * costs the estate the guarantee on it. One optional key gives every repository the\n * same freedom, which is the order of preference this module documents.\n * ⚠️ NOT VALIDATED. GitHub's expression grammar is the vendor's; a bad condition is\n * caught by `actionlint` in the `workflow lint` job, which is where it belongs.\n */\n readonly if?: string;\n readonly env?: Readonly<Record<string, string>>;\n} & (\n | {\n readonly uses: string;\n readonly with?: Readonly<Record<string, string | number>>;\n readonly run?: never;\n }\n | { readonly run: string; readonly uses?: never; readonly with?: never }\n);\n\n/**\n * ⛔ A REASON MUST BE WRITTEN, NOT COMPUTED. `'' extends R` is true for the empty literal\n * AND for the wide `string` type, so both collapse to `never` and fail to typecheck:\n * `reason: ''` is refused, and so is `reason: someVariable`. Only a string literal\n * written at the call site survives — which is the whole point, because a reason\n * assembled at runtime is a reason nobody reads in the diff.\n * Verified against TypeScript 7.0.2 on 2026-09-22; `tests/repo-shape-reason.test.ts`\n * compiles the refusals and asserts they still fail.\n */\nexport type Stated<R extends string> = '' extends R ? never : R;\n\n/** Paths this package renders. A deviation names one of these, not an arbitrary file. */\nexport type RenderedPath =\n | '.changeset/config.json'\n | '.github/actionlint.yaml'\n | '.github/dependabot.yml'\n | '.github/workflows/ci.yml'\n | '.github/workflows/security.yml';\n\nexport interface RepoShapeException {\n /** The rendered file this repository does not take from the renderer. */\n readonly file: RenderedPath;\n /** Why — written at the exception, in a sentence a stranger can act on. */\n readonly reason: string;\n /** `YYYY-MM-DD` the exception was taken, so a stale one is visible. */\n readonly since: string;\n}\n\ninterface ExceptionInput {\n readonly file: RenderedPath;\n readonly reason: string;\n readonly since: string;\n}\n\n/**\n * Declare that this repository keeps its own copy of a rendered file.\n *\n * except({\n * file: '.github/workflows/ci.yml',\n * reason: 'Payload needs Node >=24.15, which the rendered job does not install',\n * since: '2026-09-22',\n * })\n *\n * ⛔ THE DRIFT CHECK STOPS CHECKING THAT FILE. That is the trade: an exception buys the\n * freedom to hand-edit one file and pays for it by losing the guarantee on that file.\n * Prefer widening `RepoShape` — then every repository benefits and nothing is lost.\n */\nexport function except<const E extends ExceptionInput>(\n exception: E & { readonly reason: Stated<E['reason']> },\n): RepoShapeException {\n return {\n file: exception.file,\n reason: requireSentence('reason', exception.reason),\n since: requireIsoDate(exception.since),\n };\n}\n\nexport interface ExtraJob {\n /** The job key in `jobs:`, e.g. `package`. Lower-case, dashes allowed. */\n readonly id: string;\n /**\n * The job's `name:`, which is also the status-check context. It joins the `ci`\n * aggregate's `needs`, so the branch ruleset still requires exactly one check.\n */\n readonly name: string;\n /** Why this repository has a job the other twelve do not. */\n readonly reason: string;\n /** Steps after checkout. The prologue (checkout, bun, install) is rendered for you. */\n readonly steps: readonly JobStep[];\n /** Job ids this one waits for. Default: none, so it runs beside `check`. */\n readonly needs?: readonly string[];\n /** `false` skips the rendered bun prologue — for a job that needs another toolchain. */\n readonly bun?: boolean;\n /**\n * `timeout-minutes:` for this job. Omitted takes GitHub's 360-minute default.\n *\n * ⚠️ AN INPUT BECAUSE A HUNG JOB IS NOT A FAILED JOB. Measured 2026-09-22:\n * homeflare-blog's `runtime` job drives Playwright against a local workerd, and a\n * browser that never reaches its first paint holds a self-hosted slot for six hours\n * rather than reporting red. On a 3-slot pool that is the whole pool. Only a job\n * that starts something with its own wait — a browser, a server, a container — needs\n * this; `check` does not, because `bun run check` exits.\n */\n readonly timeout?: number;\n}\n\ninterface ExtraJobInput extends Omit<ExtraJob, 'needs' | 'bun' | 'timeout'> {\n readonly needs?: readonly string[];\n readonly bun?: boolean;\n readonly timeout?: number;\n}\n\n/**\n * Declare a job this repository needs and the standard does not have.\n * ⛔ Same rule as `except`: the reason is a written literal or it does not compile.\n */\nexport function extraJob<const J extends ExtraJobInput>(\n job: J & { readonly reason: Stated<J['reason']> },\n): ExtraJob {\n if (job.steps.length === 0) {\n throw new Error(`repo-shape: extra job \"${job.id}\" has no steps`);\n }\n return {\n bun: job.bun ?? true,\n id: requireJobId(job.id),\n // ⚠️ A DISPLAY NAME IS NOT A SENTENCE. `build` and `consumer smoke test` are both\n // correct job names; only the REASON has to be prose, because only the reason is\n // there for a reader rather than for the checks list.\n name: requireNonEmpty('name', job.name),\n needs: [...(job.needs ?? [])],\n reason: requireSentence('reason', job.reason),\n steps: [...job.steps],\n ...(job.timeout === undefined ? {} : { timeout: requireMinutes(job.timeout) }),\n };\n}\n\nexport interface RepoShape {\n /** Repository owner — a user or organization login. */\n readonly owner: string;\n /** Repository name, which is also the checkout directory name. */\n readonly repository: string;\n /** Where its jobs run. */\n readonly runner: RepoRunner;\n /**\n * `true` when the repository publishes an npm tarball. It decides `access` in the\n * changeset config and whether `privatePackages` is written at all — the two keys\n * that differ between `homeflare-kit` and every other repository.\n */\n readonly publishes: boolean;\n /**\n * Node major to install before Bun, for a repository whose own gate needs a real\n * `node` on `PATH`. Omit it — twelve of fourteen repositories are Bun-only.\n *\n * ⛔ AN INPUT, NOT AN EXCEPTION, AND THE MEASUREMENT SAYS WHY. The mini's job image\n * carries no node at all (ubuntu-latest always did), so a Bun-only prologue is right\n * for most of the estate and *silently wrong* for two repositories:\n * · homeflare-alerts — tests/alchemy-import.test.ts spawns `node` to prove the\n * modules load the way the Alchemy CLI (`node …/cli.js`) loads them. Without it,\n * three tests fail with `Executable not found in $PATH: \"node\"` (measured on the\n * runner, 2026-09-22).\n * · homeflare-blog — Payload requires Node >= 24.15, so every lane needs it.\n * Both repositories carried the same hand-written `actions/setup-node@v6` block\n * before this existed. Excepting `ci.yml` instead would hand the estate's two most\n * complicated CI files back to hand-editing, which is the opposite of the point.\n *\n * ⚠️ `package-manager-cache: false` IS RENDERED WITH IT. Bun does the installing here;\n * letting setup-node prime an npm cache costs time and caches nothing anyone reads.\n */\n readonly node?: number;\n /** Jobs beyond `check` and `workflows`. Each carries its own stated reason. */\n readonly extraJobs?: readonly ExtraJob[];\n /** Rendered files this repository keeps its own copy of, each with a reason. */\n readonly exceptions?: readonly RepoShapeException[];\n}\n\n/**\n * The validated Node major this shape asks for, or `undefined` for a Bun-only prologue.\n * ★ `RepoShape` is a plain object, so this is where `node:` is checked — at render, not\n * at declaration. A bad value fails the refresh rather than the job.\n */\nexport function nodeMajor(shape: RepoShape): number | undefined {\n return shape.node === undefined ? undefined : requireMajor(shape.node);\n}\n\n/** `runs-on:` for a runner. */\nexport function runsOn(runner: RepoRunner): string {\n return runner === 'mini' ? '[self-hosted, homeflare-mini]' : 'ubuntu-latest';\n}\n\n/** Whether a rendered path is excepted by this shape. */\nexport function isExcepted(shape: RepoShape, file: RenderedPath): boolean {\n return (shape.exceptions ?? []).some((exception) => exception.file === file);\n}\n",
7
7
  "/**\n * The smallest YAML writer that renders a job's steps, and nothing else.\n *\n * ★ NOT A YAML LIBRARY, DELIBERATELY. A general serializer would take a dependency every\n * consumer of `@homeflare/config` inherits, and it would strip the comments — which in\n * this house are the product. The rendered workflows are written as text with holes;\n * only the step lists, whose shape varies per repository, go through here.\n *\n * ⚠️ Bun.YAML.stringify EXISTS (`Object.keys(Bun.YAML)` is `[\"parse\", \"stringify\"]`,\n * measured against Bun 1.4.0 on 2026-09-23) but its output does not fit these rules:\n * (a) a multi-line `run:` comes out as a double-quoted string with `\\n` escapes, e.g.\n * `run: \"echo a\\necho b\\n\"`, not a `|` block scalar; (b) `09:00` comes out UNQUOTED,\n * e.g. `cron: 09:00`, the YAML 1.1 sexagesimal trap the `scalar()` comment below guards\n * against; (c) a mapping key is followed by a trailing space, e.g. `\"steps: \\n - ...\"`;\n * and (d) a plain JS object has nowhere to attach a comment, so it cannot carry one. The\n * tests parse what this writes with `Bun.YAML.parse` and compare structures, so a\n * malformed emission fails rather than shipping.\n */\nimport type { JobStep } from './shape.ts';\n\n/** Two spaces per level, the house indent and GitHub's own. */\nconst INDENT = ' ';\n\nfunction indent(depth: number): string {\n return INDENT.repeat(depth);\n}\n\n/**\n * ⛔ QUOTE ANYTHING YAML WOULD RE-READ AS SOMETHING ELSE. `09:00` is a sexagesimal number\n * in YAML 1.1 and `on`/`no` are booleans; an unquoted value that looks like either\n * reaches GitHub as the wrong type, and the symptom is a schedule that never fires\n * rather than an error.\n */\nconst PLAIN = /^[A-Za-z0-9_./][A-Za-z0-9_ ./@:+-]*$/;\nconst YAML_KEYWORD =\n /^(y|Y|n|N|on|On|ON|no|No|NO|yes|Yes|YES|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/;\n\nfunction scalar(value: string | number): string {\n if (typeof value === 'number') return String(value);\n if (value === '') return \"''\";\n const plain =\n PLAIN.test(value) &&\n !YAML_KEYWORD.test(value) &&\n !value.includes(': ') &&\n !value.includes(' #') &&\n // ⚠️ A LEADING DIGIT PLUS A COLON IS A YAML 1.1 SEXAGESIMAL NUMBER, not a string:\n // `09:00` would reach GitHub as an integer, and a schedule set to an integer\n // simply never fires. `bun run build:web` is safe because it does not start with\n // a digit, which is why this is narrower than \"contains a colon\".\n !/^\\d.*:/.test(value) &&\n value.trimEnd() === value;\n return plain ? value : `'${value.replaceAll(\"'\", \"''\")}'`;\n}\n\nfunction renderMapping(\n entries: Readonly<Record<string, string | number>>,\n depth: number,\n): string[] {\n return Object.entries(entries).map(([key, value]) => `${indent(depth)}${key}: ${scalar(value)}`);\n}\n\n/**\n * Trim trailing `\\n` characters the way `command.replace(/\\n+$/, '')` used to, but\n * linear instead of backtracking — the same pattern as `normalizeBaseUrl` in\n * `packages/distilled-netbox/src/credentials.ts`. Exported so a test can compare it\n * against the old regex directly.\n *\n * ⚠️ Linear on purpose: a `/\\n+$/` regex backtracks polynomially on a long run of\n * \"\\n\" that is not at the end (CodeQL js/polynomial-redos), and `command` here is\n * repository-configured step text.\n */\nexport function trimTrailingNewlines(value: string): string {\n let end = value.length;\n while (end > 0 && value.charCodeAt(end - 1) === 10) end--;\n return value.slice(0, end);\n}\n\n/**\n * ★ BLOCK SCALAR FOR EVERY MULTI-LINE `run:`. A folded or quoted form would join the\n * lines, and a shell script whose `if` and `then` end up on one line is a syntax error\n * at job time rather than at lint time. `|` keeps them exactly as written.\n */\nfunction renderRun(command: string, depth: number): string[] {\n const lines = trimTrailingNewlines(command).split('\\n');\n if (lines.length === 1) return [`${indent(depth)}run: ${scalar(lines[0] ?? '')}`];\n return [`${indent(depth)}run: |`, ...lines.map((line) => `${indent(depth + 1)}${line}`)];\n}\n\n/** One step, as the lines of a `steps:` list item at `depth`. */\nexport function renderStep(step: JobStep, depth: number): string[] {\n const lines: string[] = [];\n const body =\n step.uses === undefined\n ? renderRun(step.run ?? '', depth + 1)\n : [`${indent(depth + 1)}uses: ${step.uses}`];\n\n // ★ `name:` then `if:` then the body, because that is the order a reader scans: what\n // this step is, whether it runs, what it does. All three are ordinary mapping keys\n // to GitHub, so the order is for the person reading the diff, not the parser.\n const head: string[] = [];\n if (step.name !== undefined) head.push(`${indent(depth + 1)}name: ${scalar(step.name)}`);\n if (step.if !== undefined) head.push(`${indent(depth + 1)}if: ${scalar(step.if)}`);\n\n // ⚠️ The first line of the item carries the dash, whichever key it turns out to be —\n // an unnamed, unconditional step still inlines its `run:` or `uses:` after the dash\n // and lets any block body follow at its own indent.\n const [first = '', ...rest] = [...head, ...body];\n lines.push(`${indent(depth)}- ${first.trimStart()}`, ...rest);\n\n if (step.with !== undefined && Object.keys(step.with).length > 0) {\n lines.push(`${indent(depth + 1)}with:`, ...renderMapping(step.with, depth + 2));\n }\n if (step.env !== undefined && Object.keys(step.env).length > 0) {\n lines.push(`${indent(depth + 1)}env:`, ...renderMapping(step.env, depth + 2));\n }\n return lines;\n}\n\n/** A whole `steps:` list, already indented for a job at `depth`. */\nexport function renderSteps(steps: readonly JobStep[], depth: number): string {\n return steps.flatMap((step) => renderStep(step, depth)).join('\\n');\n}\n",
8
8
  "/**\n * `.github/workflows/ci.yml`, rendered.\n *\n * ★ THE COMMENTS ARE PART OF THE RENDER, NOT DECORATION. Thirteen repositories carried\n * thirteen hand-edited copies of the same reasoning; measured 2026-09-22, the header\n * comment alone had four different wordings and the actionlint block had three. Written\n * here once, every repository gets the same explanation, and correcting it is one edit.\n *\n * ⛔ THE AGGREGATE `ci` JOB IS THE ONLY REQUIRED CHECK. Every other job feeds it through\n * `needs`, so adding a job never means editing a branch ruleset. `repoShapeChecks()`\n * returns exactly `['ci', 'secret scan']`, which is what `declareRepoPolicy` requires.\n */\nimport type { ExtraJob, JobStep, RepoShape } from './shape.ts';\nimport { nodeMajor, runsOn } from './shape.ts';\nimport { renderSteps } from './yaml.ts';\n\n/** Bun the whole estate is pinned to. One line, one place. */\nexport const BUN_VERSION = '1.4.0';\n/** actionlint the `workflow lint` job runs, and the mini's job image preloads. */\nexport const ACTIONLINT_VERSION = '1.7.12';\n\nconst CHECKOUT = 'actions/checkout@v7';\nconst SETUP_BUN = 'oven-sh/setup-bun@v2';\nconst SETUP_NODE = 'actions/setup-node@v6';\n\nfunction runnerNote(shape: RepoShape): string {\n if (shape.runner !== 'mini') {\n return \"# ⚠️ RUNS ON GitHub-HOSTED `ubuntu-latest`. Only a public repository should: hosted minutes\\n# are refused for this account's private repos (billing lock, 2026-09-22).\\n\";\n }\n return [\n '# ⚠️ RUNS ON THE MINI (`[self-hosted, homeflare-mini]`): GitHub-hosted runners are refused for',\n '# this account (billing lock, 2026-09-22), so every job here runs in a one-job Linux arm64',\n \"# container started by homeflare-mini's src/ci-runner (docs/ci-runner.md there). Each action\",\n `# below was checked for linux/arm64. The job image preloads bun ${\n BUN_VERSION\n } where setup-bun`,\n '# looks, gh, actionlint, gitleaks, typos and shfmt; no node on PATH.',\n '',\n ].join('\\n');\n}\n\nconst HEADER = `# Pull requests: is this code correct?\n#\n# 🤖 RENDERED BY @homeflare/config — DO NOT EDIT THIS FILE BY HAND.\n# Its input is this repository's \\`repo-shape.ts\\`. Change that, then:\n# bun run repo-shape:refresh\n# A hand edit is reverted by the next refresh and fails \\`bun run check\\` before that.\n# A file this repository must own outright is declared as an \\`except({...})\\` with a\n# reason, which stops the check from comparing it — see @homeflare/config/repo-shape.\n#\n# ★ SPLIT BY CONCERN, NOT ONE BIG JOB — AND NOT ONE JOB PER COMMAND EITHER: lanes that\n# share a prologue are steps inside \\`check\\`, so the install is paid once.\n# ⛔ EVERY ACTION IS FIRST-PARTY OR THE VENDOR'S OWN, PINNED TO A MAJOR TAG.\n`;\n\nconst TRIGGER = `name: ci\n\non:\n # ⛔ NO \\`push: branches: [main]\\`. Every commit reaches main through a pull request whose\n # \\`ci\\` had to be green — the branch ruleset requires it and carries no bypass actors —\n # so a second run on the squash commit recomputed an answer it already had. Measured\n # 2026-09-15..22 across the estate: 613 of 619 main-push runs had a head_sha identical\n # to the merge_commit_sha of an already-green PR. That duplication was ~41% of the Mac\n # mini's entire CI load.\n # ⚠️ WHAT THIS GIVES UP, SAID OUT LOUD: the ~4% of merges whose base DID move between the\n # PR run and the squash no longer get a post-merge re-test. That run gated nothing — it\n # reported after main already had the commit — and the next PR, which branches from the\n # merged main, is what actually catches a semantic conflict.\n pull_request:\n\npermissions:\n contents: read\n\nconcurrency:\n group: ci-\\${{ github.ref }}\n cancel-in-progress: true\n`;\n\nconst CHECK_NOTE = ` # ── One job, one install ────────────────────────────────────────────────────\n # ★ WAS 3 SEPARATE JOBS: lint and format, types, tests. They shared an identical\n # prologue — checkout, setup-bun, \\`bun install --frozen-lockfile\\` — and the install,\n # not the check, was the cost: measured on the mini 2026-09-22, install p50 51s /\n # p90 81s against 1-3s for the command the install existed to enable. 3 jobs meant 3\n # installs and 3 containers to compute one answer, and on a 3-slot pool that was one\n # pull request asking for every slot to answer a single question.\n # ⛔ ONE STEP: \\`bun run check\\`, THE REPOSITORY'S OWN GATE, NOT A COPY OF ITS LANES.\n # The house rule is that CI runs the same command a person runs, and a workflow that\n # re-lists \\`lint\\`, \\`types\\`, \\`test\\` is a second copy of that command which can quietly\n # check LESS than the local gate. Measured 2026-09-22: \\`bun run check\\` in homeflare-kit\n # is \\`lint && types && build && test\\`, and its tests/dist.test.ts SKIPS ITSELF when\n # dist/ is absent — so a workflow running lint/types/test without the build would drop\n # that test silently and still report green. homeflare-alerts' check runs\n # \\`check:types\\` and \\`build:web\\`; homeflare-subnet-calc's delegates to \\`verify\\`. All\n # fourteen repositories have a \\`check\\` script and every one is local-only — no network,\n # no deploy. Checked, not assumed.\n # ★ THE SPLIT OF RESPONSIBILITY: this renderer owns the PLUMBING — triggers, permissions,\n # concurrency, runner, action versions, the aggregate gate — and the repository owns\n # WHAT ITS GATE RUNS, in package.json, where a change to it shows up in that\n # repository's own diff rather than in a workflow nobody reads.\n # ⚠️ THE TRADE, STATED: a red X says \\`check\\` rather than naming the lane. \\`bun run check\\`\n # short-circuits on the first failure and its output names the lane, which is the same\n # signal a person gets locally.`;\n\nconst WORKFLOWS_NOTE = ` # ★ \\`workflow lint\\` STAYS ITS OWN JOB on purpose: it needs no \\`bun install\\` at all\n # (actionlint is preloaded on the mini's job image — measured 5s end to end), so\n # folding it into \\`check\\` would make a YAML-only change pay the install for nothing.`;\n\nconst ACTIONLINT_NOTE = ` # ⛔ THERE IS NO FIRST-PARTY actionlint ACTION, and the npm package by that name is\n # an unrelated wasm port with no \\`bin\\`. The vendor's own documented CI path is\n # \\`download-actionlint.bash\\` — but that script does NOT verify a checksum (read at\n # the v${ACTIONLINT_VERSION} tag, 2026-09-16: \\`curl -L \"$url\" | tar xvz\\`, no sha256sum anywhere).\n # rhysd's releases DO publish a \\`_checksums.txt\\` per version; this verifies against\n # that instead of trusting the tarball on receipt.\n # ⚠️ ARCH FROM THE RUNNER, NOT HARD-CODED: the mini's runners are arm64, and an amd64\n # binary fails with \"exec format error\" (the checksum file lists both).`;\n\nconst INSTALL_ACTIONLINT = `set -euo pipefail\nversion=${ACTIONLINT_VERSION}\nif command -v actionlint >/dev/null 2>&1 &&\n [ \"$(actionlint -version 2>/dev/null | sed -n 1p)\" = \"\\${version}\" ]; then\n cp \"$(command -v actionlint)\" ./actionlint\n exit 0\nfi\ncase \"$(uname -m)\" in\n x86_64) arch=amd64 ;;\n aarch64 | arm64) arch=arm64 ;;\n *) echo \"unsupported architecture $(uname -m)\" >&2; exit 1 ;;\nesac\nfile=\"actionlint_\\${version}_linux_\\${arch}.tar.gz\"\nbase=\"https://github.com/rhysd/actionlint/releases/download/v\\${version}\"\ncurl -sSfLO \"\\${base}/\\${file}\"\ncurl -sSfLO \"\\${base}/actionlint_\\${version}_checksums.txt\"\ngrep \" \\${file}\\\\$\" \"actionlint_\\${version}_checksums.txt\" | sha256sum -c -\ntar xzf \"\\${file}\" actionlint`;\n\nconst AGGREGATE_NOTE = ` # ── The one check a branch rule can require ─────────────────────────────────\n # ★ A single required check that depends on all of them. Without this, adding a job\n # means editing the branch ruleset too, and forgetting to means the new job is\n # advisory without anyone noticing.\n # ⛔ FAILS ON ANYTHING OTHER THAN success, INCLUDING skipped. A job result has exactly\n # four values: success, failure, cancelled, skipped. Checking only the first two lets\n # a skipped job — a bad \\`if:\\` condition, a misconfigured dependency, a runner picking\n # up nothing — report this aggregate green with a required job never having run.`;\n\nconst VERIFY = `if [ \"\\${{ contains(needs.*.result, 'failure') }}\" = \"true\" ] || \\\\\n [ \"\\${{ contains(needs.*.result, 'cancelled') }}\" = \"true\" ] || \\\\\n [ \"\\${{ contains(needs.*.result, 'skipped') }}\" = \"true\" ]; then\n echo \"one or more required jobs did not succeed: \\${{ toJSON(needs.*.result) }}\" >&2\n exit 1\nfi`;\n\nconst NODE_NOTE = ` # ⛔ REAL NODE, NOT BUN'S SHIM, AND ONLY WHERE THE GATE NEEDS IT. The mini's job\n # image has no node on PATH (ubuntu-latest always did), so a repository whose own\n # \\`check\\` spawns \\`node\\` — or whose framework demands a Node runtime — fails with\n # \\`Executable not found in $PATH: \"node\"\\` without this. Declared as \\`node:\\` in\n # repo-shape.ts, so it is one input rather than a hand-edited block per repository.\n # ⚠️ NO PACKAGE-MANAGER CACHE: bun does the installing, so priming npm's cache costs\n # time and caches nothing anything here reads.`;\n\nfunction prologue(shape: RepoShape): string {\n const node = nodeMajor(shape);\n const bun: JobStep[] = [\n { uses: SETUP_BUN, with: { 'bun-version': BUN_VERSION } },\n { run: 'bun install --frozen-lockfile' },\n ];\n if (node === undefined) return renderSteps([{ uses: CHECKOUT }, ...bun], 3);\n return [\n renderSteps([{ uses: CHECKOUT }], 3),\n NODE_NOTE,\n renderSteps(\n [{ uses: SETUP_NODE, with: { 'node-version': node, 'package-manager-cache': 'false' } }],\n 3,\n ),\n renderSteps(bun, 3),\n ].join('\\n');\n}\n\nfunction renderExtraJob(job: ExtraJob, shape: RepoShape, on: string): string {\n const needs =\n (job.needs ?? []).length === 0 ? '' : ` needs: [${(job.needs ?? []).join(', ')}]\\n`;\n // ★ A TIMEOUT IS THE JOB'S, NOT THE SHAPE'S. Only a job that starts something with its\n // own wait needs one, and it is rendered where a reader looks for it.\n const timeout = job.timeout === undefined ? '' : ` timeout-minutes: ${job.timeout}\\n`;\n const steps =\n job.bun === false\n ? renderSteps([{ uses: CHECKOUT }, ...job.steps], 3)\n : [prologue(shape), renderSteps(job.steps, 3)].join('\\n');\n // ★ The stated reason is rendered into the file. A job nobody can explain is a job\n // nobody dares delete, so the explanation travels with it.\n return ` # ★ NOT PART OF THE STANDARD SHAPE — ${job.reason}\n ${job.id}:\n name: ${job.name}\n${needs} runs-on: ${on}\n${timeout} steps:\n${steps}\n`;\n}\n\n/** The whole `ci.yml` for a shape. */\nexport function renderCi(shape: RepoShape): string {\n const on = runsOn(shape.runner);\n const extras = shape.extraJobs ?? [];\n const needs = ['check', 'workflows', ...extras.map((job) => job.id)];\n\n return `${HEADER}${runnerNote(shape)}${TRIGGER}\njobs:\n${CHECK_NOTE}\n check:\n name: check\n runs-on: ${on}\n steps:\n${prologue(shape)}\n${renderSteps([{ run: 'bun run check' }], 3)}\n\n${WORKFLOWS_NOTE}\n workflows:\n name: workflow lint\n runs-on: ${on}\n steps:\n${renderSteps([{ uses: CHECKOUT }], 3)}\n${ACTIONLINT_NOTE}\n${renderSteps([{ name: 'Install actionlint (checksum-verified)', run: INSTALL_ACTIONLINT }], 3)}\n${renderSteps([{ name: 'Lint workflows', run: './actionlint -color' }], 3)}\n\n${extras.map((job) => `${renderExtraJob(job, shape, on)}\\n`).join('')}${AGGREGATE_NOTE}\n ci:\n name: ci\n if: always()\n needs: [${needs.join(', ')}]\n runs-on: ${on}\n steps:\n${renderSteps([{ name: 'Verify every job succeeded', run: VERIFY }], 3)}\n`;\n}\n",
9
9
  "/**\n * `@homeflare/config/versions`: the one aligned set of versions the estate runs.\n *\n * import { ESTATE_VERSIONS } from '@homeflare/config/versions';\n * ESTATE_VERSIONS.alchemy; // '2.0.0-beta.79'\n *\n * ★ WHY IT LIVES HERE. Until this file, the set had two homes and neither was reachable from\n * outside the kit. Bun was `BUN_VERSION` in repo-shape/ci.ts. Every other pin sat in\n * homeflare-kit's root `catalog`, which is authoritative but never published. Measured\n * 2026-09-22, read-only, from each repository's lockfile: seven app repositories resolve\n * `alchemy` 2.0.0-beta.78, the monorepo resolves beta.77 with `effect` rc.112, and one app\n * resolves TypeScript 5.9.3. None of them had anything to compare against.\n * `@homeflare/config` is the one package almost every estate repository already depends\n * on (repo-shape.ts header: 13 of 14), so the set goes here.\n *\n * ⛔ THE KIT'S ROOT CATALOG STAYS AUTHORITATIVE. `tests/estate-versions.test.ts` fails when any\n * value below differs from it, and it also fails when `bun` differs from `BUN_VERSION` or\n * from the root's `packageManager`. A catalog bump that does not reach this file is\n * therefore a red test in the same PR. Otherwise the estate would drift from a published\n * source that looks correct.\n *\n * ★ RUNTIME PINS FOLLOW THE PINNED ALCHEMY RELEASE, NEVER THE OTHER WAY. `effect` and\n * `@distilled.cloud/cloudflare` are the versions `alchemy@2.0.0-beta.79` was built and\n * tested against. That release's own `dependencies` pin every `@distilled.cloud/*` at\n * exactly 1.0.0-rc.12, and its workspace overrides pin `effect` 4.0.0-rc.115\n * (alchemy-run/alchemy `pnpm-workspace.yaml@v2.0.0-beta.79#overrides`). The test reads the\n * installed `alchemy/package.json` to prove the distilled pin. These values move only in\n * the same PR as the `alchemy` bump.\n *\n * ⚠️ PUBLISHING THE SET ENFORCES NOTHING. No consumer check reads it yet. Wiring it into\n * `checkProject`, so that each repository's lockfile is compared with it, is a separate,\n * estate-wide rollout. Doing that here would turn the next bump into thirteen red CIs at\n * once.\n */\nimport { BUN_VERSION } from './repo-shape/ci.ts';\n\n/** The package names the set pins. One entry per tool or runtime the whole estate shares. */\nexport type EstatePackage =\n | 'bun'\n | 'alchemy'\n | 'effect'\n | '@distilled.cloud/cloudflare'\n | 'typescript'\n | 'oxfmt'\n | 'oxlint'\n | '@types/bun';\n\n/**\n * Exact versions, never ranges. A range is what lets two repositories resolve two different\n * versions from one line, which is exactly the drift this set exists to name.\n */\nexport const ESTATE_VERSIONS: Readonly<Record<EstatePackage, string>> = {\n bun: BUN_VERSION,\n alchemy: '2.0.0-beta.79',\n effect: '4.0.0-rc.115',\n '@distilled.cloud/cloudflare': '1.0.0-rc.12',\n typescript: '7.0.2',\n oxfmt: '0.68.0',\n oxlint: '1.83.0',\n '@types/bun': '1.4.2',\n};\n"
10
10
  ],
11
- "mappings": "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",
12
- "debugId": "613673276BC65A5864756E2164756E21",
11
+ "mappings": "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",
12
+ "debugId": "3E8EB110EB4CEAA964756E2164756E21",
13
13
  "names": []
14
14
  }
@@ -0,0 +1,75 @@
1
+ # Retired rendered files — the fossil, and how it is cleared
2
+
3
+ `renderRepoShape` only emits the files a shape asks for today. `refreshRepoShape` only
4
+ writes what it renders. Put those two together and a file the renderer stops emitting is
5
+ never deleted by a refresh — it just stops being updated, silently, in every repository
6
+ that had already taken it. `RETIRED_FILES` in `src/repo-shape/retired.ts` is how that stops
7
+ being permanent.
8
+
9
+ ## MEASURED 2026-09-24: the gap that motivated this
10
+
11
+ `@homeflare/config` 0.12.0 ([kit PR 199](https://github.com/taslabs-net/homeflare-kit/pull/199))
12
+ stopped rendering `.github/workflows/dependabot-automerge.yml` — `taslabs-net/homeflare-bumper`
13
+ carries a kit release into a consumer now, over `workflow_dispatch`, so the workflow that used
14
+ to arm auto-merge on Dependabot's `homeflare` group had nothing left to do.
15
+
16
+ Every repository that had already refreshed to 0.12.0 before this module existed
17
+ (`homeflare-wiki` bump PR 19, `homeflare-mini` bump PR 47) kept the dead file. Nothing said
18
+ so: `driftInRepoShape` only ever compared the paths the **current** shape renders, and a
19
+ retired path is not one of those, so it was invisible to the one check whose whole job is
20
+ to say when a committed file and the renderer disagree.
21
+
22
+ ## The two halves
23
+
24
+ **`driftInRepoShape` (`repo-shape check`) reports a retired path that is merely present.**
25
+ It does not read the file to decide anything beyond "is it there" — proving the file is
26
+ provably ours is `refreshRepoShape`'s decision to make, not the read-only check's. So
27
+ `bun run repo-shape:refresh` fails on a retired fossil exactly the way it fails on ordinary
28
+ drift:
29
+
30
+ ```
31
+ ✗ .github/workflows/dependabot-automerge.yml: retired in @homeflare/config@0.12.0
32
+ (kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper carries
33
+ a kit release into each consumer now, over the kit's own release workflow) but still
34
+ present — run `bun run repo-shape:refresh` to remove it
35
+ ```
36
+
37
+ **`refreshRepoShape` deletes a retired path only when it can prove it rendered it.** The
38
+ proof is the generated-file header: every renderer in `src/repo-shape/` writes
39
+ `🤖 RENDERED BY @homeflare/config` into the file it emits, and `wasRenderedByUs` is nothing
40
+ more than a check for that line. A retired path whose content carries it is deleted. A
41
+ retired path whose content does not — a hand-written replacement, a fork of the old
42
+ rendered file kept on purpose, or coincidental content that landed at the same name — is
43
+ left exactly as it is, and reported back as `refused`, never `removed`.
44
+
45
+ ⚠️ **A refused file still fails `repo-shape check`.** The check does not know or care
46
+ whether the file is provably ours; it only knows the retired path is present. There is no
47
+ `except()` for a retired path — a repository that genuinely wants to keep a hand-written
48
+ file at that exact name has to remove it or rename it itself; `repo-shape` will not delete
49
+ someone else's file to make its own check pass.
50
+
51
+ ## Why the header, not a stored copy of the old rendered text
52
+
53
+ The retired `dependabot-automerge.yml` varied by `shape.runner` and by repository name —
54
+ there is no single byte-for-byte "the rendered form" to compare against across the estate's
55
+ history, and a renderer that has been deleted cannot be asked to re-render its old output.
56
+ The header line is the one thing every variant, in every repository, always carried.
57
+
58
+ ## Retiring a path, going forward
59
+
60
+ Add an entry to `RETIRED_FILES` in `src/repo-shape/retired.ts`: the path, the
61
+ `@homeflare/config` version whose release stops rendering it, and a written reason — same
62
+ rule as `except()`, enforced the same way. Remove the path from `render.ts` in the same
63
+ changeset. A path can never move back into `RenderedPath`; `repo-shape-retired.test.ts`
64
+ asserts the two lists stay disjoint, because a path in both would have the renderer writing
65
+ a file this module is also trying to delete, every single refresh.
66
+
67
+ ## How this reaches a consumer
68
+
69
+ A patch release of `@homeflare/config` ships the new `RETIRED_FILES` entry.
70
+ `taslabs-net/homeflare-bumper` opens the bump pull request in each consumer the way it
71
+ always does. Its own `bun run repo-shape:refresh` — the same step every bump PR already
72
+ needs, [documented here](./repo-shape.md#bumping-homeflareconfig-will-go-red-before-it-goes-green) — now also deletes
73
+ the fossil, so the PR's diff touches `.github/workflows/`. **The bumper holds a PR that
74
+ touches that directory for Tim rather than auto-merging it**, which is the correct,
75
+ existing behavior for a workflow-file change — not something this module has to arrange.
@@ -173,6 +173,17 @@ The alternative — a check that tolerated an older render — is a check that t
173
173
  drift, which is the thing this exists to stop. A loud, one-command failure is the better
174
174
  half of that trade, but it is a trade.
175
175
 
176
+ ## A file the renderer stops emitting is deleted, not left behind
177
+
178
+ `renderRepoShape` only emits what a shape asks for today, and `refreshRepoShape` only
179
+ writes what it renders — so on its own, a file the renderer retires would never get
180
+ deleted by a refresh; it would just stop being updated, in every repository that already
181
+ had it. `RETIRED_FILES` in `src/repo-shape/retired.ts` closes that gap: `repo-shape check`
182
+ flags a retired path that is still present, and a refresh deletes it — but only when the
183
+ file provably carries this package's generated-file header, never a hand-written file that
184
+ happens to share the name. [repo-shape-retired.md](repo-shape-retired.md) has the mechanism
185
+ and the measurement that found the first fossil.
186
+
176
187
  ## What this does not render yet
177
188
 
178
189
  `.github/workflows/release.yml`. Thirteen copies, 166–197 lines each, thirteen distinct
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/config",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Shared tsconfig, oxlint, oxfmt, and non-npm release helpers for HomeFlare projects.",
5
5
  "license": "MIT",
6
6
  "author": "Timothy Schneider",
@@ -18,6 +18,7 @@
18
18
  * The one exception is line endings, normalised so a CRLF checkout is not "drift".
19
19
  */
20
20
  import { type RenderedRepo, renderRepoShape } from './render.ts';
21
+ import { RETIRED_FILES, retiredFileProblem } from './retired.ts';
21
22
  import type { RenderedPath, RepoShape, RepoShapeException } from './shape.ts';
22
23
 
23
24
  /** One thing to fix, in the imperative — the same `Problem` shape `./check` reports. */
@@ -69,8 +70,24 @@ function duplicateExceptions(exceptions: readonly RepoShapeException[]): Problem
69
70
  );
70
71
  }
71
72
 
73
+ /**
74
+ * ★ A RETIRED PATH IS REPORTED WHETHER OR NOT IT IS PROVABLY OURS. This check only reads
75
+ * the file to see if it is THERE — `wasRenderedByUs` is `refreshRepoShape`'s call to
76
+ * make, because only the writer should decide whether to delete. Reporting here is what
77
+ * makes a fossil visible even in a repository that only ever runs `--check` in CI.
78
+ */
79
+ async function retiredFilesPresent(projectDir: string): Promise<Problem[]> {
80
+ const problems: Problem[] = [];
81
+ for (const file of RETIRED_FILES) {
82
+ if ((await readIfPresent(`${projectDir}/${file.path}`)) !== undefined) {
83
+ problems.push(retiredFileProblem(file));
84
+ }
85
+ }
86
+ return problems;
87
+ }
88
+
72
89
  export interface DriftReport {
73
- /** Empty when the committed files are the rendered ones. */
90
+ /** Empty when the committed files are the rendered ones and no retired path lingers. */
74
91
  readonly problems: readonly Problem[];
75
92
  /** Paths whose committed text differs from the render, excluding excepted files. */
76
93
  readonly drifted: readonly string[];
@@ -93,6 +110,7 @@ export async function driftInRepoShape(projectDir: string, shape: RepoShape): Pr
93
110
  const problems: Problem[] = [
94
111
  ...duplicateExceptions(exceptions),
95
112
  ...staleExceptions(rendered, exceptions),
113
+ ...(await retiredFilesPresent(projectDir)),
96
114
  ];
97
115
  const drifted: string[] = [];
98
116
 
@@ -36,6 +36,17 @@ export function requireIsoDate(value: string): string {
36
36
  return value;
37
37
  }
38
38
 
39
+ /**
40
+ * A retired file names the `@homeflare/config` release that stopped rendering it, so
41
+ * anyone reading `retired.ts` can find the changeset that made the call.
42
+ */
43
+ export function requireSemver(value: string): string {
44
+ if (!/^\d+\.\d+\.\d+$/.test(value)) {
45
+ throw new Error(`repo-shape: retiredIn must be a released x.y.z, got ${JSON.stringify(value)}`);
46
+ }
47
+ return value;
48
+ }
49
+
39
50
  /**
40
51
  * ⛔ A MAJOR, NOT A RANGE, AND NOT A FLOAT. `actions/setup-node` takes `node-version: 24`
41
52
  * and resolves the newest 24.x; a fractional or negative value renders YAML the action
@@ -12,9 +12,17 @@
12
12
  * ⚠️ IT NEVER WRITES AN EXCEPTED FILE. Refreshing a file the repository declared it owns
13
13
  * would overwrite the deviation the reason was written for — the one destructive thing
14
14
  * this could do, and the one it must not.
15
+ *
16
+ * ⚠️ IT DELETES A RETIRED FILE ONLY WHEN IT CAN PROVE IT RENDERED IT. `RETIRED_FILES`
17
+ * (`retired.ts`) names every path this package used to render; `wasRenderedByUs` is the
18
+ * proof — the file still carries the generated-file header. A retired path present
19
+ * without that header is left alone and reported as refused, on the same reasoning as
20
+ * never overwriting an excepted file: this writer only ever removes what it is sure is
21
+ * its own.
15
22
  */
16
23
  import { REFRESH_COMMAND, driftInRepoShape, exceptionSummary } from './drift.ts';
17
24
  import { renderRepoShape } from './render.ts';
25
+ import { RETIRED_FILES, wasRenderedByUs } from './retired.ts';
18
26
  import { type RepoShape, isExcepted } from './shape.ts';
19
27
  import type { RenderedPath } from './shape.ts';
20
28
 
@@ -25,9 +33,13 @@ export interface RefreshResult {
25
33
  readonly unchanged: readonly string[];
26
34
  /** Paths skipped because the shape declares an exception for them. */
27
35
  readonly skipped: readonly string[];
36
+ /** Retired paths deleted because they still carried the generated-file header. */
37
+ readonly removed: readonly string[];
38
+ /** Retired paths left alone because they did not — provably hand-written, not ours. */
39
+ readonly refused: readonly string[];
28
40
  }
29
41
 
30
- /** Write a repository's rendered files into `projectDir`. */
42
+ /** Write a repository's rendered files into `projectDir`, and clear its rendered fossils. */
31
43
  export async function refreshRepoShape(
32
44
  projectDir: string,
33
45
  shape: RepoShape,
@@ -52,7 +64,36 @@ export async function refreshRepoShape(
52
64
  written.push(path);
53
65
  }
54
66
 
55
- return { skipped, unchanged, written };
67
+ const { refused, removed } = await clearRetiredFiles(projectDir);
68
+
69
+ return { refused, removed, skipped, unchanged, written };
70
+ }
71
+
72
+ /**
73
+ * Delete every retired path this package can prove it rendered; leave every other one, and
74
+ * say so. A path absent from `projectDir` is neither removed nor refused — there is
75
+ * nothing there to have an opinion about.
76
+ */
77
+ async function clearRetiredFiles(
78
+ projectDir: string,
79
+ ): Promise<{ removed: string[]; refused: string[] }> {
80
+ const removed: string[] = [];
81
+ const refused: string[] = [];
82
+
83
+ for (const retiredFile of RETIRED_FILES) {
84
+ const target = `${projectDir}/${retiredFile.path}`;
85
+ const file = Bun.file(target);
86
+ if (!(await file.exists())) continue;
87
+
88
+ if (wasRenderedByUs(await file.text())) {
89
+ await file.delete();
90
+ removed.push(retiredFile.path);
91
+ } else {
92
+ refused.push(retiredFile.path);
93
+ }
94
+ }
95
+
96
+ return { refused, removed };
56
97
  }
57
98
 
58
99
  /**
@@ -108,6 +149,18 @@ export async function repoShapeCli(
108
149
  ...result.written.map((path) => `wrote ${path}`),
109
150
  ...result.unchanged.map((path) => `ok ${path}`),
110
151
  ...result.skipped.map((path) => `excepted ${path}`),
152
+ ...result.removed.map((path) => `removed ${path} (retired; carried our header)`),
111
153
  ]);
154
+ // ★ ON ITS OWN LINE, TO STDERR: a refused retired file is the one outcome here that
155
+ // still needs a person. `--check` (above) keeps failing on it — it reports any retired
156
+ // path that is present, proof or not — so this is not the only place it is said, but
157
+ // it is the only place that says WHY refresh did not just fix it.
158
+ await say(
159
+ Bun.stderr,
160
+ result.refused.map(
161
+ (path) =>
162
+ `refused ${path} — present but not provably ours; not deleted. See docs/repo-shape-retired.md.`,
163
+ ),
164
+ );
112
165
  return 0;
113
166
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Rendered paths this package stopped rendering — the "no fossils" list.
3
+ *
4
+ * ★ WHY THIS EXISTS. `renderRepoShape` only emits the files a shape asks for today; it
5
+ * never says what it used to emit, and `refreshRepoShape` only writes what it renders —
6
+ * so a file retired from the renderer was never deleted by a refresh. It just stopped
7
+ * being updated, silently, forever, in every repository that had already taken it.
8
+ *
9
+ * MEASURED 2026-09-24: `@homeflare/config` 0.12.0 (kit PR 199) stopped rendering
10
+ * `.github/workflows/dependabot-automerge.yml`. Every consumer that had refreshed to
11
+ * 0.12.0 before this module existed (homeflare-wiki bump PR 19, homeflare-mini bump
12
+ * PR 47) kept the dead file, and `driftInRepoShape` never said so — it only compares
13
+ * paths the CURRENT shape renders, and a retired path is not one of those.
14
+ *
15
+ * ⛔ RETIRING A PATH IS A ONE-WAY DOOR, NOT A RENAME. Once a path is here it can never
16
+ * become a `RenderedPath` again: `render.ts` would start writing a file this module is
17
+ * also trying to delete, and every refresh would fight itself. Give the replacement a
18
+ * new path instead. `repo-shape-retired.test.ts` asserts the two lists never overlap.
19
+ *
20
+ * ⚠️ DELETION IS GATED ON PROOF, NOT ON THE PATH ALONE. A repository can have a
21
+ * hand-written file sitting at the exact path a retired renderer used to own — a fork of
22
+ * the old rendered file, kept on purpose, or unrelated content that just landed there.
23
+ * `wasRenderedByUs` is the proof: every renderer in this directory writes the
24
+ * `🤖 RENDERED BY @homeflare/config` line into its header (grep the directory for it),
25
+ * and that line is the one thing a hand-written file has no reason to contain.
26
+ * `refreshRepoShape` deletes a retired path only when the line is present; otherwise it
27
+ * refuses and reports the path, the same way it refuses to overwrite an excepted file.
28
+ */
29
+ import { requireSemver, requireSentence } from './guards.ts';
30
+
31
+ /**
32
+ * A path `@homeflare/config` no longer renders, but may still find committed in a
33
+ * repository that refreshed before the retirement. Extend this union at the same time a
34
+ * path is added to `RETIRED_FILES` below — never reuse one already there.
35
+ */
36
+ export type RetiredPath = '.github/workflows/dependabot-automerge.yml';
37
+
38
+ export interface RetiredFile {
39
+ /** The path this package rendered, once. */
40
+ readonly path: RetiredPath;
41
+ /** The `@homeflare/config` version whose release stopped rendering it. */
42
+ readonly retiredIn: string;
43
+ /** Why, and which kit change did it — written at the retirement, like `except()`'s reason. */
44
+ readonly reason: string;
45
+ }
46
+
47
+ function retire(file: RetiredFile): RetiredFile {
48
+ return {
49
+ path: file.path,
50
+ reason: requireSentence('retired reason', file.reason),
51
+ retiredIn: requireSemver(file.retiredIn),
52
+ };
53
+ }
54
+
55
+ /** Every path `@homeflare/config` used to render, oldest retirement first. */
56
+ export const RETIRED_FILES: readonly RetiredFile[] = [
57
+ retire({
58
+ path: '.github/workflows/dependabot-automerge.yml',
59
+ reason:
60
+ 'kit PR 199 retired the Dependabot @homeflare group; taslabs-net/homeflare-bumper ' +
61
+ "carries a kit release into each consumer now, over the kit's own release workflow",
62
+ retiredIn: '0.12.0',
63
+ }),
64
+ ];
65
+
66
+ /**
67
+ * The substring every generated file's header has carried since this package's first
68
+ * renderer. Kept independent of the four current renderers' own copies of this text
69
+ * (`ci.ts`, `security.ts`, `dependabot.ts`, `companions.ts`) on purpose: a future wording
70
+ * change to the live header must not stop this module recognising a file rendered under
71
+ * the old one.
72
+ */
73
+ export const GENERATED_FILE_MARKER = '🤖 RENDERED BY @homeflare/config';
74
+
75
+ /**
76
+ * Whether `content` is provably a file this package once wrote.
77
+ *
78
+ * ⚠️ THE MARKER HAS TO START A LINE OF ITS OWN, NOT MERELY OCCUR SOMEWHERE IN THE TEXT.
79
+ * Every real header carries it as a whole comment line — `# 🤖 RENDERED BY
80
+ * @homeflare/config — DO NOT EDIT…`, third line in every renderer in this directory, so
81
+ * this does not require it to be the FIRST line — but a bare `content.includes(...)`
82
+ * would also match a hand-written file that merely *talks about* the marker, e.g. a
83
+ * comment reading "this file used to be 🤖 RENDERED BY @homeflare/config before it was
84
+ * retired; keeping it by hand now" — adversarial review, 2026-09-24, caught this exact
85
+ * case before it shipped. Requiring the marker at the start of a trimmed line is what a
86
+ * sentence built around it, rather than a header line consisting of it, cannot satisfy.
87
+ * `repo-shape-retired.test.ts` asserts the mid-sentence form stays refused.
88
+ *
89
+ * ⚠️ STILL A PREFIX CHECK ON THAT LINE, NOT A FULL-LINE EXACT MATCH. The retired renderer
90
+ * varied its trailing header prose by `shape.runner` and by repository name, and a
91
+ * future wording change to what follows the marker on a live renderer's header must not
92
+ * stop this recognising a file rendered under the old wording — only the marker itself,
93
+ * `🤖 RENDERED BY @homeflare/config`, has been constant across every renderer this
94
+ * package has ever shipped.
95
+ */
96
+ export function wasRenderedByUs(content: string): boolean {
97
+ return content
98
+ .split('\n')
99
+ .some((line) => line.trimStart().startsWith(`# ${GENERATED_FILE_MARKER}`));
100
+ }
101
+
102
+ /**
103
+ * The message `driftInRepoShape` reports for a retired path that is still present, and
104
+ * `repoShapeCli --check` prints verbatim. Names the fix command either way — whether that
105
+ * command can actually remove the file depends on `wasRenderedByUs`, which only
106
+ * `refreshRepoShape` (holding the file's contents) can decide.
107
+ */
108
+ export function retiredFileProblem(file: RetiredFile): string {
109
+ return (
110
+ `${file.path}: retired in @homeflare/config@${file.retiredIn} (${file.reason}) ` +
111
+ 'but still present — run `bun run repo-shape:refresh` to remove it'
112
+ );
113
+ }
package/src/repo-shape.ts CHANGED
@@ -54,6 +54,14 @@ export {
54
54
  RENDERED_PATHS,
55
55
  renderRepoShape,
56
56
  } from './repo-shape/render.ts';
57
+ export {
58
+ type RetiredFile,
59
+ type RetiredPath,
60
+ GENERATED_FILE_MARKER,
61
+ RETIRED_FILES,
62
+ retiredFileProblem,
63
+ wasRenderedByUs,
64
+ } from './repo-shape/retired.ts';
57
65
  export { renderSecurity } from './repo-shape/security.ts';
58
66
  export {
59
67
  type ExtraJob,