@monte3l/groundwork 0.1.0-next.1 → 1.0.0-rc.3

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.
Files changed (170) hide show
  1. package/README.md +16 -8
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors
3
+ // SPDX-License-Identifier: NOASSERTION
4
+
5
+ /**
6
+ * Checks (and, with `--fix`, inserts) an SPDX copyright/license header on
7
+ * every tracked source file -- OpenSSF Best Practices' Gold-level
8
+ * `copyright_per_file`/`license_per_file` criteria.
9
+ *
10
+ * Two disjoint mechanisms cover every tracked file (`git ls-files`), never
11
+ * both for the same file:
12
+ * - **An inline header** (`SPDX-FileCopyrightText` + `SPDX-License-Identifier`,
13
+ * as a line comment) on every `.ts`/`.mjs`/`.js`/`.sh`/`.yml`/`.yaml`
14
+ * file, except anything a `REUSE.toml` annotation already claims (a
15
+ * generated file with that extension, such as a committed lockfile, has
16
+ * nothing meaningful to carry a hand-written header in).
17
+ * - **A `REUSE.toml` annotation** for everything else: data files and
18
+ * dotfiles with no comment syntax of their own (JSON, Markdown,
19
+ * `.gitignore`, `.npmrc`, `.node-version`, `.prettierignore`, `LICENSE`).
20
+ *
21
+ * `--check` (default, the `pnpm verify` gate's `license-headers` step):
22
+ * exits 1 and lists every header-eligible file missing the header, AND every
23
+ * tracked file that is neither header-eligible nor matched by a `REUSE.toml`
24
+ * glob (a gap in the REUSE annotation set itself, not just a missing
25
+ * header). `--fix`: inserts the header into every header-eligible file
26
+ * missing one, after a shebang line if present, and leaves every other line
27
+ * untouched.
28
+ */
29
+ import process from "node:process";
30
+ import { execFileSync } from "node:child_process";
31
+ import { readFileSync, writeFileSync } from "node:fs";
32
+ import { extname, join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ const repoRoot = join(fileURLToPath(import.meta.url), "..", "..");
36
+
37
+ /** Extensions that carry an inline SPDX header via a `#`/`//` line comment. */
38
+ const HEADER_EXTENSIONS = new Set([
39
+ ".ts",
40
+ ".mjs",
41
+ ".js",
42
+ ".sh",
43
+ ".yml",
44
+ ".yaml",
45
+ ]);
46
+
47
+ const HASH_COMMENT_EXTENSIONS = new Set([".sh", ".yml", ".yaml"]);
48
+
49
+ const COPYRIGHT_MARKER = "SPDX-FileCopyrightText:";
50
+ const LICENSE_MARKER = "SPDX-License-Identifier:";
51
+
52
+ function headerLines(ext) {
53
+ const prefix = HASH_COMMENT_EXTENSIONS.has(ext) ? "#" : "//";
54
+ return [
55
+ `${prefix} SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors`,
56
+ `${prefix} SPDX-License-Identifier: NOASSERTION`,
57
+ ];
58
+ }
59
+
60
+ /**
61
+ * Every path git tracks, repo-root-relative, forward-slash-separated.
62
+ * `-z` NUL-delimits the output instead of newline-delimiting it, so a path
63
+ * containing a character `core.quotePath` would otherwise quote/escape
64
+ * (non-ASCII, a literal newline) comes back exactly as git stores it,
65
+ * matching what `readFileSync`/`writeFileSync` expect.
66
+ */
67
+ function listTrackedFiles() {
68
+ return execFileSync("git", ["-C", repoRoot, "ls-files", "-z"], {
69
+ encoding: "utf8",
70
+ })
71
+ .split("\0")
72
+ .filter((line) => line.length > 0);
73
+ }
74
+
75
+ /**
76
+ * A tiny, deliberately non-general glob-to-regex translator: only `**`
77
+ * (any number of path segments, including zero) and `*` (anything but `/`)
78
+ * are supported, which is all `REUSE.toml`'s own patterns use. Every other
79
+ * character is treated literally.
80
+ */
81
+ function globToRegExp(glob) {
82
+ let pattern = "";
83
+ for (let i = 0; i < glob.length; i++) {
84
+ const ch = glob[i];
85
+ if (ch === "*" && glob[i + 1] === "*") {
86
+ if (glob[i + 2] === "/") {
87
+ pattern += "(?:.*/)?";
88
+ i += 2;
89
+ } else {
90
+ pattern += ".*";
91
+ i += 1;
92
+ }
93
+ continue;
94
+ }
95
+ if (ch === "*") {
96
+ pattern += "[^/]*";
97
+ continue;
98
+ }
99
+ if (/[.+^${}()|[\]\\?]/.test(ch)) {
100
+ pattern += `\\${ch}`;
101
+ continue;
102
+ }
103
+ pattern += ch;
104
+ }
105
+ return new RegExp(`^${pattern}$`);
106
+ }
107
+
108
+ /**
109
+ * Every `path = [...]` glob string across every `[[annotations]]` block in
110
+ * `REUSE.toml`. Only the array form (`path = ["a", "b"]`) is supported, not
111
+ * REUSE's single-string form (`path = "a"`) or single-quoted strings -- if
112
+ * that ever changes, a silently-dropped annotation would quietly shrink
113
+ * REUSE.toml's coverage, so this counts `[[annotations]]` blocks against
114
+ * blocks that actually yielded a `path` array and fails loudly on a
115
+ * mismatch rather than trusting an empty result.
116
+ */
117
+ function readReuseGlobs() {
118
+ const content = readFileSync(join(repoRoot, "REUSE.toml"), "utf8");
119
+ const blockCount = (content.match(/^\[\[annotations]]/gm) ?? []).length;
120
+ const globs = [];
121
+ const blockPattern = /\[\[annotations]][\s\S]*?path\s*=\s*\[([\s\S]*?)]/g;
122
+ let parsedBlocks = 0;
123
+ for (const match of content.matchAll(blockPattern)) {
124
+ parsedBlocks += 1;
125
+ const arrayBody = match[1] ?? "";
126
+ for (const stringMatch of arrayBody.matchAll(/"([^"]*)"/g)) {
127
+ const glob = stringMatch[1];
128
+ if (glob !== undefined) globs.push(glob);
129
+ }
130
+ }
131
+ if (parsedBlocks !== blockCount) {
132
+ throw new Error(
133
+ `check-license-headers: REUSE.toml has ${blockCount} [[annotations]] block(s) but only ` +
134
+ `${parsedBlocks} had a parseable \`path = [...]\` array -- this parser only supports ` +
135
+ "that array form; check for a single-string or single-quoted `path` value.",
136
+ );
137
+ }
138
+ return globs;
139
+ }
140
+
141
+ function hasHeader(content) {
142
+ const firstLines = content.split("\n", 6).join("\n");
143
+ return (
144
+ firstLines.includes(COPYRIGHT_MARKER) && firstLines.includes(LICENSE_MARKER)
145
+ );
146
+ }
147
+
148
+ function withHeaderInserted(content, ext) {
149
+ const lines = content.split("\n");
150
+ const hasShebang = lines[0]?.startsWith("#!") ?? false;
151
+ const insertAt = hasShebang ? 1 : 0;
152
+ lines.splice(insertAt, 0, ...headerLines(ext), "");
153
+ return lines.join("\n");
154
+ }
155
+
156
+ function main() {
157
+ const fix = process.argv.includes("--fix");
158
+ const tracked = listTrackedFiles();
159
+ const reuseGlobs = readReuseGlobs().map(globToRegExp);
160
+ const isReuseExempt = (path) => reuseGlobs.some((re) => re.test(path));
161
+
162
+ const headerEligible = tracked.filter(
163
+ (path) => HEADER_EXTENSIONS.has(extname(path)) && !isReuseExempt(path),
164
+ );
165
+ const uncovered = tracked.filter(
166
+ (path) => !HEADER_EXTENSIONS.has(extname(path)) && !isReuseExempt(path),
167
+ );
168
+
169
+ if (uncovered.length > 0) {
170
+ console.error(
171
+ `check-license-headers: ${uncovered.length} tracked file(s) are neither header-eligible ` +
172
+ "nor matched by a REUSE.toml annotation -- add a glob to REUSE.toml or an extension to " +
173
+ "HEADER_EXTENSIONS:",
174
+ );
175
+ for (const path of uncovered) console.error(` ${path}`);
176
+ process.exit(1);
177
+ }
178
+
179
+ let fixedCount = 0;
180
+ const missing = [];
181
+
182
+ for (const path of headerEligible) {
183
+ const absolute = join(repoRoot, path);
184
+ const content = readFileSync(absolute, "utf8");
185
+ if (hasHeader(content)) continue;
186
+ if (fix) {
187
+ writeFileSync(absolute, withHeaderInserted(content, extname(path)));
188
+ fixedCount += 1;
189
+ } else {
190
+ missing.push(path);
191
+ }
192
+ }
193
+
194
+ if (fix) {
195
+ console.log(`check-license-headers: fixed ${fixedCount} file(s)`);
196
+ return;
197
+ }
198
+
199
+ if (missing.length > 0) {
200
+ console.error(
201
+ `check-license-headers: ${missing.length} file(s) missing an SPDX header ` +
202
+ "(SPDX-FileCopyrightText + SPDX-License-Identifier):",
203
+ );
204
+ for (const path of missing) console.error(` ${path}`);
205
+ console.error(
206
+ "Run `node bin/check-license-headers.mjs --fix` to insert them.",
207
+ );
208
+ process.exit(1);
209
+ }
210
+
211
+ console.log(
212
+ `check-license-headers: ok -- ${headerEligible.length} file(s) carry an SPDX header, ` +
213
+ `${tracked.length - headerEligible.length} covered by REUSE.toml`,
214
+ );
215
+ }
216
+
217
+ main();
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors
3
+ // SPDX-License-Identifier: NOASSERTION
4
+
5
+ /**
6
+ * Checks that the version this project's `package.json` currently declares
7
+ * has not already been published to npm -- catches a version bump that
8
+ * silently duplicates one already live (a stale manual edit, a rebase that
9
+ * dropped a changeset), surfaced here rather than as a cryptic registry
10
+ * rejection deep inside the release workflow's `publish` job.
11
+ *
12
+ * Deliberately NOT one of this pack's `pnpm verify` steps: with changesets,
13
+ * `package.json`'s version on the release branch is always the one just
14
+ * published between releases, so running this on every ordinary push would
15
+ * fail every single time until the next version PR lands. Invoke it
16
+ * directly, once, right before packing -- see `release.yml`'s `pack` job.
17
+ *
18
+ * A no-op (`checked: 0`, exit 0), not a failure, when:
19
+ * - `package.json` has `private: true` (the baseline's own default) -- there
20
+ * is nothing to publish, so nothing to check.
21
+ * - The registry lookup fails for a reason other than "package not found"
22
+ * (network error, timeout, registry outage, an unparseable body) -- this
23
+ * is a best-effort due-diligence check, not a hard dependency on the
24
+ * registry being reachable; a real duplicate-version publish still fails
25
+ * at `npm stage publish` itself, which is the actual enforcement point.
26
+ *
27
+ * A package that has never been published at all (the registry 404s) is
28
+ * also fine: there is no prior version to collide with. A 404 can also mean
29
+ * a scoped/restricted package the anonymous registry lookup can't see --
30
+ * this check has no npm auth token to look further, so it reports that
31
+ * possibility rather than asserting the package has definitely never
32
+ * published.
33
+ *
34
+ * A non-private `package.json` missing `name` or `version` is a real
35
+ * misconfiguration, not something to wave through: the version and dts-deps
36
+ * gates only make sense once both fields say what's actually being shipped.
37
+ */
38
+ import process from "node:process";
39
+ import { readFileSync, realpathSync } from "node:fs";
40
+ import { join } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ const repoRoot = join(fileURLToPath(import.meta.url), "..", "..");
44
+ const REGISTRY_TIMEOUT_MS = 10_000;
45
+
46
+ /**
47
+ * @param {string} name
48
+ * @param {typeof fetch} fetchImpl
49
+ * @returns {Promise<{ ok: true, versions: string[] } | { ok: false, reason: string }>}
50
+ * `ok: false` means the lookup could not be completed (network error,
51
+ * timeout, a non-404 error status, an unparseable body) -- distinct from
52
+ * "the package has never been published" (a 404), which resolves
53
+ * `{ ok: true, versions: [] }`.
54
+ */
55
+ export async function fetchPublishedVersions(name, fetchImpl) {
56
+ let response;
57
+ try {
58
+ response = await fetchImpl(
59
+ `https://registry.npmjs.org/${encodeURIComponent(name).replaceAll("%40", "@")}`,
60
+ { signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS) },
61
+ );
62
+ } catch (cause) {
63
+ return {
64
+ ok: false,
65
+ reason: `request failed: ${cause instanceof Error ? cause.message : String(cause)}`,
66
+ };
67
+ }
68
+ if (response.status === 404) {
69
+ return { ok: true, versions: [] };
70
+ }
71
+ if (!response.ok) {
72
+ return {
73
+ ok: false,
74
+ reason: `registry responded with HTTP ${String(response.status)}`,
75
+ };
76
+ }
77
+ let body;
78
+ try {
79
+ body = await response.json();
80
+ } catch {
81
+ return { ok: false, reason: "registry response body was not valid JSON" };
82
+ }
83
+ const versions = body?.versions;
84
+ if (versions === null || typeof versions !== "object") {
85
+ return {
86
+ ok: false,
87
+ reason: 'registry response had no usable "versions" field',
88
+ };
89
+ }
90
+ return { ok: true, versions: Object.keys(versions) };
91
+ }
92
+
93
+ async function main() {
94
+ const pkgPath = join(repoRoot, "package.json");
95
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
96
+
97
+ if (pkg?.private === true) {
98
+ console.log("check-publish-version: package.json is private -- skipping");
99
+ return;
100
+ }
101
+ const name = pkg?.name;
102
+ const version = pkg?.version;
103
+ if (typeof name !== "string" || name.length === 0) {
104
+ console.error(
105
+ "check-publish-version: package.json has no name, but is not private -- " +
106
+ "either set private: true, or give it a real name",
107
+ );
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ if (typeof version !== "string" || version.length === 0) {
112
+ console.error("check-publish-version: package.json has no version");
113
+ process.exitCode = 1;
114
+ return;
115
+ }
116
+
117
+ const result = await fetchPublishedVersions(name, fetch);
118
+ if (!result.ok) {
119
+ console.log(
120
+ `check-publish-version: could not check the npm registry for "${name}" -- skipping ` +
121
+ `(${result.reason}; best-effort check, a real duplicate version still fails at publish time)`,
122
+ );
123
+ return;
124
+ }
125
+ if (result.versions.includes(version)) {
126
+ console.error(
127
+ `check-publish-version: ${name}@${version} is already published -- bump the version ` +
128
+ "(or add a changeset, if this project uses one) before releasing again",
129
+ );
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ if (result.versions.length === 0) {
134
+ console.log(
135
+ `check-publish-version: ok -- ${name}@${version} (the registry has no published versions ` +
136
+ "for this name -- either it has never published, or it's a scoped/restricted package an " +
137
+ "anonymous lookup can't see)",
138
+ );
139
+ return;
140
+ }
141
+ console.log(`check-publish-version: ok -- ${name}@${version} is unpublished`);
142
+ }
143
+
144
+ if (
145
+ process.argv[1] !== undefined &&
146
+ fileURLToPath(import.meta.url) === realpathSync(process.argv[1])
147
+ ) {
148
+ await main();
149
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors
3
+ // SPDX-License-Identifier: NOASSERTION
4
+
5
+ /**
6
+ * Translates the one `pnpm publish` invocation changesets makes for a packed
7
+ * tarball into the equivalent `npm stage publish`, for `pnpm-publish-shim.mjs`.
8
+ *
9
+ * Changesets runs (see its `lib/pnpm.ts`):
10
+ * pnpm publish <tarball> [--json] --access <a> --tag <t> --no-git-checks [--otp <c>]
11
+ *
12
+ * `--json` and `--no-git-checks` are pnpm-only and dropped. Anything not listed
13
+ * is refused rather than guessed at: if changesets ever starts passing a flag
14
+ * this does not understand, publishing must stop, not silently do something else.
15
+ *
16
+ * Staged, not direct: a trusted publisher created since 2026-09-03 defaults to
17
+ * `npm stage publish` only, npm's own default (and explicit recommendation) --
18
+ * a maintainer must separately run `npm stage approve <id>` (2FA, never
19
+ * automatable) before a staged version actually goes live. `npm stage publish`
20
+ * accepts the same `--access`/`--tag`/`--otp`/`--provenance` flags as
21
+ * `npm publish` ("parity with npm publish" per npm's own CLI reference); the
22
+ * command itself is `npm stage publish`, not a flag on `npm publish`.
23
+ */
24
+
25
+ const DROPPED = new Set(["--json", "--no-git-checks"]);
26
+ const WITH_VALUE = new Set(["--access", "--tag", "--otp"]);
27
+
28
+ /**
29
+ * @param {string[]} args the arguments following `pnpm publish`
30
+ * @returns {string[]} the argument vector for `npm stage publish`
31
+ */
32
+ export function toNpmStagePublishArgs(args) {
33
+ let tarball;
34
+ const flags = [];
35
+
36
+ for (let i = 0; i < args.length; i += 1) {
37
+ const arg = args[i];
38
+ if (DROPPED.has(arg)) continue;
39
+
40
+ if (WITH_VALUE.has(arg)) {
41
+ const value = args[i + 1];
42
+ if (value === undefined || value.startsWith("--")) {
43
+ throw new Error(`${arg} needs a value`);
44
+ }
45
+ flags.push(arg, value);
46
+ i += 1;
47
+ continue;
48
+ }
49
+
50
+ if (arg.startsWith("-")) {
51
+ throw new Error(`unsupported flag ${arg}`);
52
+ }
53
+ if (tarball !== undefined) {
54
+ throw new Error(`expected one tarball, got a second argument: ${arg}`);
55
+ }
56
+ tarball = arg;
57
+ }
58
+
59
+ if (tarball === undefined) {
60
+ throw new Error(
61
+ "no tarball given: this shim only publishes packed tarballs (changesets' --from-pack-dir mode)",
62
+ );
63
+ }
64
+
65
+ // Provenance is requested explicitly rather than relied on: npm's automatic
66
+ // attestation for trusted publishing has been reported not to engage without it.
67
+ // "stage", "publish" are two separate words (the npm-stage subcommand), not
68
+ // "stage-publish" or a flag -- see npm's own CLI reference for npm-stage.
69
+ return ["stage", "publish", tarball, ...flags, "--provenance"];
70
+ }
71
+
72
+ /**
73
+ * `PATH` with `dir` removed, so the shim can find the real `pnpm` behind it.
74
+ * @param {string | undefined} pathValue
75
+ * @param {string | undefined} dir
76
+ * @param {string} separator
77
+ */
78
+ export function pathWithout(pathValue, dir, separator) {
79
+ if (pathValue === undefined || dir === undefined || dir === "") {
80
+ return pathValue ?? "";
81
+ }
82
+ return pathValue
83
+ .split(separator)
84
+ .filter((entry) => entry !== dir)
85
+ .join(separator);
86
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors
3
+ // SPDX-License-Identifier: NOASSERTION
4
+
5
+ /**
6
+ * Put ahead of the real `pnpm` on PATH by the release workflow's `publish`
7
+ * job. Everything passes straight through to pnpm except `publish`, which
8
+ * this rewrites into `npm stage publish` instead.
9
+ *
10
+ * ## Why it exists
11
+ *
12
+ * Two independent reasons this can't be `pnpm publish`, stacked:
13
+ * 1. `changeset publish` in a pnpm-managed project publishes through
14
+ * `pnpm publish`, and pnpm 12's native publish does its own OIDC
15
+ * exchange, which npmjs.com rejects (`403 OIDC permission denied`) even
16
+ * for a correctly configured trusted publisher. The npm CLI's exchange
17
+ * works.
18
+ * 2. It can't be a direct `npm publish` either: a trusted publisher created
19
+ * since 2026-09-03 defaults to `npm stage publish` only, npm's own
20
+ * explicit recommendation (see this pack's adoptNotes). A direct
21
+ * `npm publish` gets the exact same 403 "OIDC permission denied" as the
22
+ * pnpm case, for an unrelated reason: the token exchange succeeds, but
23
+ * the registry refuses the specific action.
24
+ *
25
+ * ## What it translates
26
+ *
27
+ * Only the exact `pnpm publish` invocation changesets makes -- everything
28
+ * else (install, other pnpm subcommands) passes straight through to the real
29
+ * `pnpm` unmodified. Routing only the publish keeps changesets in charge of
30
+ * everything else it does around it -- the publish plan, ordering, git tags,
31
+ * GitHub Releases -- instead of reimplementing any of that. A consequence
32
+ * worth knowing: those git tags and Releases are created the moment
33
+ * `npm stage publish` succeeds, which is *before* the package is actually
34
+ * live -- staging still needs a maintainer to run `npm stage approve <id>`
35
+ * (2FA, never automatable).
36
+ *
37
+ * ## What it refuses
38
+ *
39
+ * `toNpmStagePublishArgs` (see `./lib/npm-publish-args.mjs`) accepts only the
40
+ * flag shape changesets actually passes and throws on anything it does not
41
+ * recognise, rather than guessing a translation for an unfamiliar flag.
42
+ *
43
+ * `PNPM_SHIM_DIR` names this shim's own directory so it can be dropped from
44
+ * PATH when looking for the real pnpm (otherwise it would find itself).
45
+ * Delete this, and the workflow step that installs it, once pnpm's publish
46
+ * authenticates against npmjs.com.
47
+ */
48
+ import process from "node:process";
49
+ import { spawnSync } from "node:child_process";
50
+ import { delimiter } from "node:path";
51
+ import { pathWithout, toNpmStagePublishArgs } from "./lib/npm-publish-args.mjs";
52
+
53
+ const args = process.argv.slice(2);
54
+
55
+ let command;
56
+ let commandArgs;
57
+ if (args[0] === "publish") {
58
+ try {
59
+ command = "npm";
60
+ commandArgs = toNpmStagePublishArgs(args.slice(1));
61
+ } catch (error) {
62
+ console.error(
63
+ `pnpm-publish-shim: ${error instanceof Error ? error.message : String(error)}`,
64
+ );
65
+ process.exit(1);
66
+ }
67
+ } else {
68
+ command = "pnpm";
69
+ commandArgs = args;
70
+ }
71
+
72
+ const result = spawnSync(command, commandArgs, {
73
+ stdio: "inherit",
74
+ env: {
75
+ ...process.env,
76
+ PATH: pathWithout(process.env.PATH, process.env.PNPM_SHIM_DIR, delimiter),
77
+ },
78
+ });
79
+
80
+ if (result.error) {
81
+ console.error(
82
+ `pnpm-publish-shim: could not run ${command}: ${result.error.message}`,
83
+ );
84
+ process.exit(1);
85
+ }
86
+ process.exit(result.status ?? 1);
@@ -0,0 +1,35 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "publishing",
4
+ "description": "A release pipeline for a project that ships an npm package. Release half: release.yml (changesets version-PR / staged, provenance-attested npm publish via trusted publishing OIDC, including a direct check-publish-version.mjs call right before packing), .changeset/config.json + README.md, the pnpm-to-npm-stage-publish shim, and one build-group gate (check-dts-deps.mjs). Also ships check-license-headers.mjs and a REUSE.toml template as a lint-group gate (secret scanning and Scorecard are the separate `supply-chain` pack). Not adopt-capable: the release flow assumes decisions (registry access, npm trusted-publisher setup, a GitHub App for the version PR) too project-specific to install blind -- see adoptNotes.",
5
+ "modes": ["fresh"],
6
+ "budget": {
7
+ "agents": 0,
8
+ "skills": 0,
9
+ "hooks": 0,
10
+ "workflows": 1,
11
+ "scripts": 2
12
+ },
13
+ "wiring": {
14
+ "settings": {},
15
+ "packageScripts": {
16
+ "changeset": "changeset",
17
+ "version:packages": "changeset version && pnpm install --lockfile-only"
18
+ },
19
+ "verifySteps": [
20
+ {
21
+ "id": "dts-deps",
22
+ "group": "build",
23
+ "name": "Check .d.ts dependency declarations",
24
+ "cmd": ["node", "bin/check-dts-deps.mjs"]
25
+ },
26
+ {
27
+ "id": "license-headers",
28
+ "group": "lint",
29
+ "name": "Check SPDX license headers",
30
+ "cmd": ["node", "bin/check-license-headers.mjs"]
31
+ }
32
+ ]
33
+ },
34
+ "adoptNotes": "REQUIRED right after install, before the first pnpm verify -- two one-time steps, or pnpm verify fails immediately, not from a real regression but from two setup gaps this pack cannot close itself: (1) `pnpm add -D @changesets/cli` -- the wiring contract only extends package.json's scripts, never its dependencies (see templates/packs/README.md), so the wired `changeset`/`version:packages` scripts reference a binary knip's own unlisted-binaries check correctly flags as missing until this is installed; (2) `node bin/check-license-headers.mjs --fix`, then commit the result -- templates/core has never had a license-header gate before this pack, so every one of its own files (hooks, bin/ scripts, CI workflows, the placeholder src/tests, and the workflows of any other installed pack such as `github` or `supply-chain`) is missing the SPDX header this pack's own license-headers gate now requires, the same one-time cost this repo itself paid when it adopted the same gate (see CLAUDE.md's Definition of Done). In a hand-copied install the CLI does not substitute tokens, so replace every literal `__PROJECT_NAME__` in the copied files (REUSE.toml, the SPDX headers in bin/, the workflows) with the project's real name first. Fresh mode only: a release pipeline encodes decisions (whether the package is actually public, which registry, whether a GitHub App backs the version PR) this pack cannot survey or guess at safely, so it is staged like every other pack (at .groundwork/packs/publishing/) but never auto-installed in adopt mode -- if an adopted project wants it, copy .groundwork/packs/publishing/files/ by hand, dropping the `.staged` suffix from every name (or copy templates/packs/publishing/files/ from this repo directly) and work through the one-time setup below. check-publish-version.mjs is deliberately NOT one of this pack's wired verify steps: with changesets, package.json's version on the release branch is always the one just published between releases, so a version-already-published check running on every ordinary push (via pnpm verify / pre-push) would fail every single time until the next version PR lands -- it is instead invoked directly as a step inside release.yml's pack job, the one place \"is this version about to collide with an already-published one\" is actually the right question to ask. The two remaining verify-group gates (dts-deps, license-headers) install anywhere a bin/verify.mjs-shaped gate runner exists; wire them into the project's real one if it differs. Both check-publish-version.mjs and check-dts-deps.mjs no-op (checked: 0, not a failure) on a package.json with `private: true` (the baseline's own default) or (check-dts-deps.mjs only) with no dist/**/*.d.ts yet -- flip `private` to `false` (and set a real `name`) once the package is actually meant to publish. The pack assumes a public package: `.changeset/config.json` ships `access: public`, npm refuses `restricted` for an unscoped name, and the publish shim always passes `--provenance`, which npm ties to a public source repository -- a private scoped package needs `access: restricted` and provenance removed from bin/lib/npm-publish-args.mjs. `@changesets/changelog-github` (and its `repo` option) is a drop-in swap for `.changeset/config.json`'s plain default changelog generator, once this project's GitHub repository is known, for changelog entries that link back to the originating PR/commit -- see `.changeset/README.md`. One-time setup before release.yml's first real run (distinct from the two pnpm-verify prerequisites above), distilled from this repo's own .claude/rules/releases.md: (1) npm cannot configure a trusted publisher for a package that doesn't exist yet -- publish once by hand with a temporary token first; (2) the trusted publisher is bound to the exact workflow filename `release.yml` -- renaming or moving it breaks publishing until reconfigured on npmjs.com; (3) set the trusted publisher's allowed actions to staged-only (`npm stage publish`, npm's own default and recommendation since 2026-09-03) -- a maintainer runs `npm stage approve <id>` (2FA, never automatable) before a version actually installs; (4) create an `npm-publish` GitHub environment (via `gh api`, not committed as JSON) with a required reviewer, restricted to the release branch, so the `publish` job itself pauses before the git tag/Release/stage-publish exist; (5) if branch protection requires status checks on every PR with no bypass actor, the version-PR job needs a GitHub App installation token (`APP_CLIENT_ID`/`APP_PRIVATE_KEY` repo secrets), not the default GITHUB_TOKEN, or those checks never trigger and the PR can never merge -- if this project has no such rule, `github-token: ${{ secrets.GITHUB_TOKEN }}` is enough and the app-token step can be dropped. release.yml pins `.github/release-tools/` (the npm CLI version) but the baseline's dependabot.yml only covers the github-actions ecosystem, so that pin goes stale unless you add an `npm` entry with `directory: \"/.github/release-tools\"` to the project's own .github/dependabot.yml. Security note, as of 2026-10-01: the npm pinned in the shipped `.github/release-tools/package-lock.json` (11.20.0) bundles undici 6.28.0, ip-address 10.5.0 and brace-expansion 5.0.9, which carry open advisories; every npm release found that day (11.20.0, 11.21.0, 12.2.0) bundles the same versions, and overrides cannot change bundled dependencies. Reading the npm tarballs and lockfile, no reach was found from what the shipped workflow runs (`npm ci --ignore-scripts`, `npm stage publish <tarball>` and `npm stage list`; the tarball is packed earlier by pnpm, not npm, and the shipped workflow configures no proxy; if your runner sets one, for example HTTPS_PROXY on a self-hosted runner behind an egress proxy, the ip-address advisories may apply), and the minimatch callers on those commands (Arborist, and tuf-js under sigstore) take their patterns from your own lockfile and from signed registry metadata, but that is analysis, not a run of the code. Re-check on the day of each release and bump the pinned npm as soon as a release bundling undici >= 6.28.1, ip-address >= 10.7.1 and brace-expansion >= 5.0.12 exists. The secret-scanning and Scorecard workflows that used to ship here are now the separate `supply-chain` pack."
35
+ }
@@ -19,9 +19,11 @@
19
19
  * oversized file is asking its reviewer to accept that debt, not silently
20
20
  * evading the gate.
21
21
  *
22
- * `ROOTS` defaults to this baseline's flat `src/`/`tests/` layout. If
23
- * `/customize` or a later refactor moves to a `packages/*` monorepo shape,
24
- * update `ROOTS` to list each package's `src`/`tests` pair.
22
+ * `ROOTS` defaults to this project's flat `src/`/`tests/` layout (not to be
23
+ * confused with the "baseline" ratchet file above -- this is about where
24
+ * your source lives, not about recorded debt). If `/customize` or a later
25
+ * refactor moves to a `packages/*` monorepo shape, update `ROOTS` to list
26
+ * each package's `src`/`tests` pair.
25
27
  *
26
28
  * Usage:
27
29
  * node bin/check-file-budget.mjs # verify (fails on growth/new-over-ceiling)
@@ -338,7 +340,7 @@ if (isEntryPoint()) {
338
340
  reporter.fail(
339
341
  ref
340
342
  ? `Could not scan the tracked roots at ${ref}: ${message}`
341
- : `Could not scan ${relative(root, root)}: ${message}`,
343
+ : `Could not scan the tracked roots: ${message}`,
342
344
  );
343
345
  reporter.finish();
344
346
  process.exit(1);
@@ -0,0 +1,29 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "name": "quality",
4
+ "description": "Two language-level review aids for a TypeScript project: a per-file size ratchet gate over src/ and tests/ (check-file-budget.mjs plus its committed baseline, wired as a build-group verify step) and a type-design-analyzer review agent that rates the type design of changed exports on four dimensions. No hooks, no settings, no scripts.",
5
+ "modes": ["fresh", "adopt"],
6
+ "budget": {
7
+ "agents": 1,
8
+ "skills": 0,
9
+ "hooks": 0,
10
+ "workflows": 0,
11
+ "scripts": 0
12
+ },
13
+ "requires": {
14
+ "paths": ["bin/lib/report.mjs"]
15
+ },
16
+ "wiring": {
17
+ "settings": {},
18
+ "packageScripts": {},
19
+ "verifySteps": [
20
+ {
21
+ "id": "file-budget",
22
+ "group": "build",
23
+ "name": "Check file budget",
24
+ "cmd": ["node", "bin/check-file-budget.mjs"]
25
+ }
26
+ ]
27
+ },
28
+ "adoptNotes": "check-file-budget.mjs's ROOTS default assumes a flat src/+tests/ layout and its ceilings are this baseline's defaults -- in an adopted project, re-point ROOTS at the project's real source layout (or drop the gate) before wiring it. check-file-budget.mjs imports ./lib/report.mjs, a baseline file that an adopted project will not have -- copy bin/lib/report.mjs alongside the gate, or treat the gate as not installable. The verify step assumes a bin/verify.mjs-shaped gate runner; if the project has none, install the agent and report the gate as not installable rather than inventing one. The type-design-analyzer agent has no such dependency and installs anywhere a .claude/agents/ directory exists; it is a read-only review spoke (model claude-opus-5 at xhigh effort, more expensive than the baseline's reviewers, which run claude-opus-5-5 at medium effort), and nothing in the baseline's hub-and-spoke instructions dispatches it by name -- ask for it after changing exported types, or add it to the project's own CLAUDE.md review step."
29
+ }