@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.4

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 +13 -5
  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 +574 -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 +21 -13
  41. package/dist/packs.js +241 -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 +44 -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 +295 -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 +41 -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,25 @@
1
+ # Changesets
2
+
3
+ A change to this package's user-visible behavior lands with a changeset: run
4
+ `pnpm changeset`, pick the bump, and commit the generated file with the PR.
5
+ Merging to the release branch opens (or updates) a
6
+ `chore(release): version packages` PR; merging that publishes to npm. See
7
+ `.github/workflows/release.yml`.
8
+
9
+ This pack ships the `changeset`/`version:packages` scripts and the release
10
+ workflow, but not the `@changesets/cli` package itself -- the wiring
11
+ contract a pack installs through never edits `package.json`'s
12
+ `dependencies`/`devDependencies` (see `templates/packs/README.md`). Add it
13
+ by hand once, **before the first `pnpm verify`, not just before the first
14
+ real release** -- both scripts reference the `changeset` binary, and
15
+ `knip`'s unlisted-binaries check correctly fails until it actually resolves:
16
+
17
+ ```
18
+ pnpm add -D @changesets/cli
19
+ ```
20
+
21
+ `config.json`'s `changelog` is the plain built-in generator
22
+ (`@changesets/cli/changelog`), which needs no further setup. Swap in
23
+ `@changesets/changelog-github` (and its `repo` option) once this project's
24
+ GitHub repository is known, for changelog entries that link back to the
25
+ originating PR/commit.
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@changesets/config@4/schema.json",
3
+ "changelog": "@changesets/cli/changelog",
4
+ "commit": false,
5
+ "access": "public",
6
+ "baseBranch": "main"
7
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "release-tools",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "Pins the exact npm version the release workflow's publish job installs, so the pin is verified by a committed lockfile's integrity hash rather than a bare version string in YAML.",
6
+ "dependencies": {
7
+ "npm": "12.2.0"
8
+ }
9
+ }
@@ -0,0 +1,295 @@
1
+ # SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors
2
+ # SPDX-License-Identifier: NOASSERTION
3
+
4
+ name: Release
5
+
6
+ # What this does: on every push to `main`, this workflow either opens a PR
7
+ # that bumps the package version and changelog, or -- once that PR has
8
+ # merged -- packs and publishes the package to npm. It runs
9
+ # [Changesets](https://github.com/changesets/changesets), the tool this
10
+ # project uses to track pending releases (each PR that changes the
11
+ # package's user-facing behavior adds a small Markdown file under
12
+ # `.changeset/` describing the change and its version bump; this workflow
13
+ # reads those files to decide what to do next).
14
+ #
15
+ # The flow, one job each:
16
+ # 1. select-mode reads the repo and decides: "version", "publish", or "none"
17
+ # 2. version opens or updates the "Version Packages" PR
18
+ # 3. pack builds, runs every gate, and packs the tarball
19
+ # 4. publish uploads that tarball to npm via trusted publishing
20
+ #
21
+ # "Trusted publishing" is npm's passwordless publish mechanism: instead of a
22
+ # long-lived npm token stored as a secret, this workflow proves its identity
23
+ # to npm via OIDC (OpenID Connect -- a short-lived, cryptographically signed
24
+ # token GitHub Actions mints for the running job, which npm verifies against
25
+ # the trusted-publisher config for this exact repo and workflow file). This
26
+ # is the shape the changesets docs recommend for trusted publishing: the one
27
+ # job that holds the OIDC token (`publish`, via `id-token: write`) never runs
28
+ # build, test, or repo-script code -- it only runs the changesets CLI and the
29
+ # pnpm shim below, with `--ignore-scripts`. `pack` holds nothing beyond
30
+ # `contents: read`.
31
+ #
32
+ # "Staged" publish (vs. a "direct" one) means `npm stage publish` uploads the
33
+ # tarball and creates the version, but it isn't installable yet until a
34
+ # maintainer separately approves it with 2FA (`npm stage approve`) -- npm's
35
+ # own stronger default over a direct `npm publish`, which would make the
36
+ # version live immediately. "Provenance" is the signed, publicly verifiable
37
+ # record npm attaches to a trusted-published package, linking it back to the
38
+ # exact commit and workflow run that built it.
39
+ #
40
+ # npm trusted publishers are configured per package, against this exact file
41
+ # name (`release.yml`, not a path) -- see this pack's adoptNotes for the
42
+ # one-time setup this depends on.
43
+ on:
44
+ push:
45
+ branches: [main]
46
+
47
+ # Reset, then grant per job.
48
+ permissions: {}
49
+
50
+ # Releases must not be cancelled midway: a half-published release is worse
51
+ # than a queued one.
52
+ concurrency:
53
+ group: ${{ github.workflow }}-${{ github.ref }}
54
+
55
+ jobs:
56
+ select-mode:
57
+ name: Select mode
58
+ runs-on: ubuntu-latest
59
+ timeout-minutes: 10
60
+ outputs:
61
+ mode: ${{ steps.select-mode.outputs.mode }}
62
+ publish-plan-artifact-id: ${{ steps.select-mode.outputs.publish-plan-artifact-id }}
63
+ permissions:
64
+ contents: read # actions/checkout
65
+ steps:
66
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
67
+ with:
68
+ persist-credentials: false
69
+ # `.node-version` is the single Node pin, so this file must never carry
70
+ # a literal version (see check-node-version.mjs). No package-manager
71
+ # cache anywhere in this file: a restored cache is an input an attacker
72
+ # can poison, and these jobs are the ones that publish.
73
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
74
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
75
+ with:
76
+ node-version-file: .node-version
77
+ package-manager-cache: false
78
+ - run: pnpm install --frozen-lockfile
79
+ - id: select-mode
80
+ uses: changesets/action/select-mode@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2
81
+
82
+ version:
83
+ name: Open version PR
84
+ needs: select-mode
85
+ if: needs.select-mode.outputs.mode == 'version'
86
+ runs-on: ubuntu-latest
87
+ timeout-minutes: 15
88
+ permissions:
89
+ contents: read # actions/checkout; see below for who does the actual writing
90
+ steps:
91
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
92
+ with:
93
+ persist-credentials: false
94
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
95
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
96
+ with:
97
+ node-version-file: .node-version
98
+ package-manager-cache: false
99
+ - run: pnpm install --frozen-lockfile
100
+ # If this project's branch protection requires status checks on every
101
+ # PR with no bypass actor, a PR opened with the default GITHUB_TOKEN
102
+ # never triggers `pull_request`-event workflows (GitHub's own
103
+ # anti-recursion rule) and could therefore never merge -- this mints a
104
+ # short-lived token from a GitHub App installed on this repo instead.
105
+ # See this pack's adoptNotes: if that's not this project's situation,
106
+ # drop this step and pass `github-token: ${{ secrets.GITHUB_TOKEN }}`
107
+ # to the version step below instead. The App needs only
108
+ # `contents: write` and `pull-requests: write`; its token auto-expires
109
+ # in an hour and this action revokes it when the job ends.
110
+ - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
111
+ id: app-token
112
+ with:
113
+ # Both created as repo secrets (`gh secret set`), not vars: the
114
+ # client ID isn't sensitive on its own, but keeping both next to
115
+ # each other avoids a second place to remember to update.
116
+ client-id: ${{ secrets.APP_CLIENT_ID }}
117
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
118
+ - uses: changesets/action/version@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2
119
+ with:
120
+ github-token: ${{ steps.app-token.outputs.token }}
121
+ script: pnpm version:packages
122
+ commit-message: "chore(release): version packages"
123
+ pr-title: "chore(release): version packages"
124
+
125
+ pack:
126
+ name: Verify and pack
127
+ needs: select-mode
128
+ if: needs.select-mode.outputs.mode == 'publish'
129
+ runs-on: ubuntu-latest
130
+ timeout-minutes: 45
131
+ outputs:
132
+ pack-dir-artifact-id: ${{ steps.pack.outputs.pack-dir-artifact-id }}
133
+ permissions:
134
+ contents: read # actions/checkout
135
+ steps:
136
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
137
+ with:
138
+ persist-credentials: false
139
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
140
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
141
+ with:
142
+ node-version-file: .node-version
143
+ package-manager-cache: false
144
+ - run: pnpm install --frozen-lockfile
145
+ # The whole gate, before a tarball exists. `pnpm verify` is one full
146
+ # run of every group, the same steps ci.yml spreads across its lanes.
147
+ - run: pnpm verify
148
+ # Deliberately not one of pnpm verify's own steps: package.json's
149
+ # version only changes in the version PR, so this check would fail
150
+ # every ordinary push between releases (the version it's checking is
151
+ # always the one already published) if it ran there. This is the one
152
+ # place "is this version about to collide with an already-published
153
+ # one" is the right question to ask -- right before packing it.
154
+ - run: node bin/check-publish-version.mjs
155
+ - id: pack
156
+ uses: changesets/action/pack@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2
157
+ with:
158
+ publish-plan-artifact-id: ${{ needs.select-mode.outputs.publish-plan-artifact-id }}
159
+
160
+ publish:
161
+ name: Publish to npm
162
+ needs: pack
163
+ runs-on: ubuntu-latest
164
+ timeout-minutes: 15
165
+ # Gates the git tag, the GitHub Release, and `npm stage publish` behind a
166
+ # required reviewer -- without this, all three already exist by the time
167
+ # a maintainer even sees there's a staged version to approve with
168
+ # `npm stage approve`. The environment (created once, by hand, via
169
+ # `gh api` -- see this pack's adoptNotes) has no branch restriction
170
+ # beyond the release branch and no wait timer: it exists to require a
171
+ # person, not to add a delay.
172
+ environment: npm-publish
173
+ permissions:
174
+ contents: write # changesets/action/publish pushes tags and creates GitHub Releases
175
+ id-token: write # npm trusted publishing (OIDC) and provenance
176
+ attestations: write # actions/attest-build-provenance on the packed tarball
177
+ steps:
178
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
179
+ with:
180
+ persist-credentials: false
181
+ - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
182
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
183
+ with:
184
+ node-version-file: .node-version
185
+ package-manager-cache: false
186
+ # Only the changesets CLI is needed here, not any package's lifecycle scripts.
187
+ - run: pnpm install --frozen-lockfile --ignore-scripts
188
+ # `npm stage publish` needs npm >= 11.15.0. The pinned npm (12.2.0)
189
+ # declares engines Node ^22.22.2 || ^24.15.0 || >=26. .node-version's
190
+ # `24` meets that on GitHub-hosted runners, whose cached 24.x is recent;
191
+ # a self-hosted runner with an older cached 24.x does not. Pinned to an
192
+ # exact version, not a range or `latest`: this job holds the OIDC token,
193
+ # and an unpinned package-manager install is exactly what OpenSSF
194
+ # Scorecard's Pinned-Dependencies check flags. `.github/release-tools/`
195
+ # pins the exact tarball via its committed lockfile's integrity hash, and
196
+ # `npm ci` verifies the install against it -- bumping the pin is then
197
+ # a normal reviewed PR (a lockfile diff), not a hand-edited version
198
+ # string.
199
+ - run: npm ci --ignore-scripts --prefix .github/release-tools
200
+ - run: echo "$GITHUB_WORKSPACE/.github/release-tools/node_modules/.bin" >> "$GITHUB_PATH"
201
+ # changesets publishes through `pnpm publish` in a pnpm-managed
202
+ # project, and pnpm 12's native publish is rejected by npmjs.com's OIDC
203
+ # exchange (403 "OIDC permission denied"). This puts a `pnpm` ahead of
204
+ # the real one that forwards everything except `publish`, which goes
205
+ # to `npm stage publish` instead (see bin/pnpm-publish-shim.mjs -- the
206
+ # trusted publisher only allows staged publishing, npm's own default
207
+ # since 2026-09-03; a direct `npm publish` gets the same 403 for a
208
+ # different reason). Remove both once pnpm's own publish authenticates.
209
+ - name: Route publish through the npm CLI
210
+ run: |
211
+ shim_dir="$RUNNER_TEMP/pnpm-shim"
212
+ mkdir -p "$shim_dir"
213
+ printf '#!/bin/sh\nexport PNPM_SHIM_DIR=%s\nexec node "%s/bin/pnpm-publish-shim.mjs" "$@"\n' \
214
+ "$shim_dir" "$GITHUB_WORKSPACE" > "$shim_dir/pnpm"
215
+ chmod +x "$shim_dir/pnpm"
216
+ echo "$shim_dir" >> "$GITHUB_PATH"
217
+ - uses: changesets/action/publish@ae32849d5ba541f9ae29e40e22a623bc13562f51 # v2
218
+ id: publish
219
+ with:
220
+ pack-dir-artifact-id: ${{ needs.pack.outputs.pack-dir-artifact-id }}
221
+ # npm provenance alone already meets most supply-chain "signed
222
+ # releases" criteria (a Sigstore-backed, OIDC-issued attestation with
223
+ # no long-lived key). These three steps add a second, independent
224
+ # proof on the GitHub Release itself: the exact tarball `pack` already
225
+ # built and tested, fetched back out of its own artifact (never
226
+ # rebuilt), attested, and attached. `if:` guards all three on
227
+ # `publish` actually having published -- `select-mode` only decides
228
+ # the job should run, not that a version was new.
229
+ # `continue-on-error: true` on all three: `npm stage publish` has
230
+ # already succeeded by this point, so a failure here (a transient
231
+ # download error, say) must never fail the whole job and leave a
232
+ # staged, tagged, unreleased version behind -- this block is
233
+ # best-effort supplementary evidence, the same status as the
234
+ # "List pending stages" step below.
235
+ - name: Fetch the packed tarball back out of the pack job's artifact
236
+ if: steps.publish.outputs.published == 'true'
237
+ continue-on-error: true
238
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
239
+ with:
240
+ artifact-ids: ${{ needs.pack.outputs.pack-dir-artifact-id }}
241
+ path: .release-artifacts
242
+ merge-multiple: true
243
+ - name: Attest build provenance for the published tarball
244
+ if: steps.publish.outputs.published == 'true'
245
+ continue-on-error: true
246
+ uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
247
+ with:
248
+ # actions/attest-build-provenance's own glob support (not the
249
+ # shell's), so `**` resolves correctly regardless of `globstar`.
250
+ subject-path: .release-artifacts/**/*.tgz
251
+ - name: Attach the attested tarball to the GitHub Release
252
+ if: steps.publish.outputs.published == 'true'
253
+ continue-on-error: true
254
+ env:
255
+ GH_TOKEN: ${{ github.token }}
256
+ run: |
257
+ # `changeset pack` writes into <outDir>/packages/*.tgz, not
258
+ # <outDir>/*.tgz -- found by path, not a bare shell glob the
259
+ # default (non-globstar) bash would pass through unexpanded on
260
+ # no match.
261
+ tarball="$(find .release-artifacts -name '*.tgz' -print -quit)"
262
+ if [ -z "$tarball" ]; then
263
+ echo "no packed tarball found under .release-artifacts/ -- skipping the release-asset upload" >&2
264
+ exit 0
265
+ fi
266
+ name="$(node -p "require('./package.json').name")"
267
+ version="$(node -p "require('./package.json').version")"
268
+ # changesets names the tag `v<version>` for a single-package
269
+ # repository (its "root" tool, which is what this baseline is: no
270
+ # `packages:` in pnpm-workspace.yaml) and `<name>@<version>` once
271
+ # the repository becomes a workspace. Upload to whichever one
272
+ # actually has a Release rather than guessing.
273
+ tag=""
274
+ for candidate in "v${version}" "${name}@${version}"; do
275
+ if gh release view "$candidate" --repo "${{ github.repository }}" >/dev/null 2>&1; then
276
+ tag="$candidate"
277
+ break
278
+ fi
279
+ done
280
+ if [ -z "$tag" ]; then
281
+ echo "cannot attach the tarball: no GitHub Release found for v${version} or ${name}@${version} -- failing this best-effort step" >&2
282
+ exit 1
283
+ fi
284
+ gh release upload "$tag" \
285
+ "$tarball" --clobber --repo "${{ github.repository }}"
286
+ # Staging succeeds unattended; going live needs a maintainer's 2FA
287
+ # (`npm stage approve <id>`, on the CLI or npmjs.com -- never
288
+ # automatable, by design). Best-effort convenience only: this job
289
+ # never logs in with a token (it publishes purely via the OIDC
290
+ # exchange scoped to the publish call itself), so `npm stage list` may
291
+ # print nothing useful -- `|| true` means that possibility never fails
292
+ # the job.
293
+ - name: List pending stages (best effort)
294
+ if: always()
295
+ run: npm stage list --package "$(node -p "require('./package.json').name")" || true
@@ -0,0 +1,28 @@
1
+ # REUSE Software (https://reuse.software) annotations for files that cannot,
2
+ # or by this project's own convention should not, carry an inline SPDX
3
+ # header -- see bin/check-license-headers.mjs for the gate that gap-checks
4
+ # this file's globs against that expectation.
5
+ version = 1
6
+ SPDX-PackageName = "__PROJECT_NAME__"
7
+
8
+ # Data files, prose, and dotfiles with no comment syntax of their own to
9
+ # carry a header in. SPDX-License-Identifier is left as NOASSERTION -- edit
10
+ # both this block and bin/check-license-headers.mjs's headerLines() once
11
+ # this project's actual license is decided (a LICENSE file, if one exists,
12
+ # is the source of truth).
13
+ [[annotations]]
14
+ path = [
15
+ "**/*.json",
16
+ "**/*.md",
17
+ "pnpm-lock.yaml",
18
+ ".node-version",
19
+ ".npmrc",
20
+ ".gitignore",
21
+ ".prettierignore",
22
+ "LICENSE",
23
+ "REUSE.toml",
24
+ ".gitleaks.toml",
25
+ ".worktreeinclude",
26
+ ]
27
+ SPDX-FileCopyrightText = "Copyright the __PROJECT_NAME__ contributors"
28
+ SPDX-License-Identifier = "NOASSERTION"
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright the __PROJECT_NAME__ contributors
3
+ // SPDX-License-Identifier: NOASSERTION
4
+
5
+ /**
6
+ * Checks that every package a published `.d.ts` file imports by bare
7
+ * specifier is declared in `package.json`'s `dependencies` (or
8
+ * `peerDependencies`) -- not only `devDependencies`, and not left
9
+ * undeclared entirely. A type import from a `devDependencies`-only package
10
+ * type-checks fine inside this repo (the dev dependency is installed here)
11
+ * but breaks a consumer's own `tsc`, since npm never installs
12
+ * `devDependencies` for a package's dependents. `publint`/`attw` don't catch
13
+ * this specific case, which is why it's a separate gate.
14
+ *
15
+ * Also honors `/// <reference types="x" />` directives (a common way a
16
+ * `.d.ts` pulls in ambient types), which the plain import/export scan below
17
+ * would miss entirely since triple-slash directives are stripped as
18
+ * comments before that scan runs. `types="node"` is exempt: `@types/node`
19
+ * is a near-universal ambient dev convenience most consumers already carry
20
+ * on their own, not something this package should have to declare as a
21
+ * real dependency just because it uses a Node builtin type.
22
+ *
23
+ * A declared package satisfies either its own name or its `@types/<name>`
24
+ * counterpart (`@types/<scope>__<name>` for a scoped `@scope/<name>`) --
25
+ * many packages only ship (or only need) the `@types/*` package, not the
26
+ * runtime one.
27
+ *
28
+ * A no-op (`checked: 0`, exit 0), not a failure, when:
29
+ * - `package.json` has `private: true` (the baseline's own default) -- a
30
+ * private package publishes no `.d.ts` files to anyone.
31
+ * - `dist/` does not exist yet (this step runs in the `build` verify group,
32
+ * after the `build` step itself -- see bin/lib/verify-steps.mjs).
33
+ * - `dist/` exists but contains no `.d.ts`/`.d.mts`/`.d.cts` file -- if this
34
+ * package is NOT private and really does mean to publish, this is worth a
35
+ * second look (a build that emits nothing, or a `tsconfig`/`exports`
36
+ * pointing declarations somewhere other than `dist/`), which the log
37
+ * message below says explicitly rather than only "skipping".
38
+ */
39
+ import process from "node:process";
40
+ import { isBuiltin } from "node:module";
41
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
42
+ import { join } from "node:path";
43
+ import { fileURLToPath } from "node:url";
44
+
45
+ const repoRoot = join(fileURLToPath(import.meta.url), "..", "..");
46
+
47
+ const DTS_EXTENSIONS = new Set([".d.ts", ".d.mts", ".d.cts"]);
48
+
49
+ function dtsExtensionOf(fileName) {
50
+ for (const ext of DTS_EXTENSIONS) {
51
+ if (fileName.endsWith(ext)) return ext;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** Every `.d.ts`/`.d.mts`/`.d.cts` file under `dir`, recursively. */
57
+ function listDtsFiles(dir) {
58
+ if (!existsSync(dir)) return [];
59
+ const out = [];
60
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
61
+ const full = join(dir, entry.name);
62
+ if (entry.isDirectory()) {
63
+ out.push(...listDtsFiles(full));
64
+ } else if (dtsExtensionOf(entry.name) !== undefined) {
65
+ out.push(full);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Every `/// <reference types="x" />` directive's `x`, extracted BEFORE
73
+ * comment-stripping (a triple-slash directive is syntactically a line
74
+ * comment, so it would otherwise vanish along with ordinary comments).
75
+ */
76
+ function referenceTypeNamesIn(content) {
77
+ const names = new Set();
78
+ const pattern = /^\/\/\/\s*<reference\s+types\s*=\s*["']([^"']+)["']\s*\/>/gm;
79
+ for (const match of content.matchAll(pattern)) {
80
+ const name = match[1];
81
+ if (name !== undefined) names.add(name);
82
+ }
83
+ return names;
84
+ }
85
+
86
+ /**
87
+ * A conservative comment strip: block comments unconditionally, and a line
88
+ * comment only when the `//` isn't immediately preceded by `:` (so a
89
+ * `"https://example.com"` string literal survives). Not a full tokenizer --
90
+ * `.d.ts` output is machine-generated by `tsc`, not hand-written prose, so
91
+ * this heuristic is proportionate to what actually shows up there.
92
+ */
93
+ function stripComments(content) {
94
+ return content
95
+ .replace(/\/\*[\s\S]*?\*\//g, "")
96
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
97
+ }
98
+
99
+ /**
100
+ * Every bare-specifier import/export/`import()` target in `content` --
101
+ * a deliberately simple regex scan, not a TypeScript AST parse. A relative
102
+ * specifier (starting with `.` or `/`) is never a package dependency, so
103
+ * only a bare specifier is returned. `content` should already have comments
104
+ * stripped (see {@link stripComments}), or a specifier-shaped string inside
105
+ * a doc comment (an `@example` importing from some other package, say)
106
+ * would be flagged as a real dependency.
107
+ */
108
+ function bareSpecifiersIn(content) {
109
+ const specifiers = new Set();
110
+ const pattern =
111
+ /(?:from|import)\s*\(?\s*["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\)/g;
112
+ for (const match of content.matchAll(pattern)) {
113
+ const specifier = match[1] ?? match[2];
114
+ if (
115
+ specifier !== undefined &&
116
+ !specifier.startsWith(".") &&
117
+ !specifier.startsWith("/")
118
+ ) {
119
+ specifiers.add(specifier);
120
+ }
121
+ }
122
+ return specifiers;
123
+ }
124
+
125
+ /** A bare specifier's package name: `@scope/name` keeps its scope, `name/sub/path` is trimmed to `name`. */
126
+ export function packageNameOf(specifier) {
127
+ const parts = specifier.split("/");
128
+ if (specifier.startsWith("@")) {
129
+ return parts.slice(0, 2).join("/");
130
+ }
131
+ return parts[0];
132
+ }
133
+
134
+ /** The `@types/*` package name that would satisfy a declared-dependency check for `packageName`. */
135
+ function typesPackageNameFor(packageName) {
136
+ if (packageName.startsWith("@")) {
137
+ const scoped = packageName.slice(1).split("/");
138
+ return `@types/${scoped[0]}__${scoped[1] ?? ""}`;
139
+ }
140
+ return `@types/${packageName}`;
141
+ }
142
+
143
+ /**
144
+ * @param {Map<string, string>} dtsFiles path -> content
145
+ * @param {{ dependencies?: Record<string, string>, peerDependencies?: Record<string, string> }} pkg
146
+ * @returns {{ packageName: string, files: string[] }[]} every package name
147
+ * referenced by a `.d.ts` import (or `/// <reference types="..." />`) that
148
+ * isn't declared as a real dependency (by its own name or its `@types/*`
149
+ * counterpart), each with the file(s) that reference it.
150
+ */
151
+ export function findUndeclaredTypeDeps(dtsFiles, pkg) {
152
+ const declared = new Set([
153
+ ...Object.keys(pkg.dependencies ?? {}),
154
+ ...Object.keys(pkg.peerDependencies ?? {}),
155
+ ]);
156
+ const byPackage = new Map();
157
+
158
+ const record = (path, packageName) => {
159
+ if (packageName === pkg.name) return;
160
+ if (isBuiltin(packageName)) return;
161
+ if (
162
+ declared.has(packageName) ||
163
+ declared.has(typesPackageNameFor(packageName))
164
+ )
165
+ return;
166
+ const files = byPackage.get(packageName) ?? [];
167
+ files.push(path);
168
+ byPackage.set(packageName, files);
169
+ };
170
+
171
+ for (const [path, rawContent] of dtsFiles) {
172
+ for (const referenceName of referenceTypeNamesIn(rawContent)) {
173
+ if (referenceName === "node") continue;
174
+ record(path, referenceName);
175
+ }
176
+ for (const specifier of bareSpecifiersIn(stripComments(rawContent))) {
177
+ record(path, packageNameOf(specifier));
178
+ }
179
+ }
180
+
181
+ return [...byPackage.entries()].map(([packageName, files]) => ({
182
+ packageName,
183
+ files,
184
+ }));
185
+ }
186
+
187
+ function main() {
188
+ const pkgPath = join(repoRoot, "package.json");
189
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
190
+
191
+ if (pkg?.private === true) {
192
+ console.log("check-dts-deps: package.json is private -- skipping");
193
+ return;
194
+ }
195
+
196
+ const distDir = join(repoRoot, "dist");
197
+ const dtsPaths = listDtsFiles(distDir);
198
+ if (dtsPaths.length === 0) {
199
+ console.log(
200
+ "check-dts-deps: no dist/**/*.d.ts found -- skipping (if this package means to publish " +
201
+ 'type declarations, check the build output path and package.json\'s "types"/"exports")',
202
+ );
203
+ return;
204
+ }
205
+
206
+ const dtsFiles = new Map(
207
+ dtsPaths.map((path) => [path, readFileSync(path, "utf8")]),
208
+ );
209
+ const undeclared = findUndeclaredTypeDeps(dtsFiles, pkg);
210
+
211
+ if (undeclared.length > 0) {
212
+ console.error(
213
+ `check-dts-deps: ${undeclared.length} package(s) are referenced by a published .d.ts file ` +
214
+ "but are not declared in dependencies/peerDependencies (a devDependencies-only or " +
215
+ "undeclared package breaks a consumer's own typecheck):",
216
+ );
217
+ for (const { packageName, files } of undeclared) {
218
+ console.error(` ${packageName} (from ${files.join(", ")})`);
219
+ }
220
+ process.exitCode = 1;
221
+ return;
222
+ }
223
+
224
+ console.log(
225
+ `check-dts-deps: ok -- checked ${dtsPaths.length} declaration file(s)`,
226
+ );
227
+ }
228
+
229
+ if (
230
+ process.argv[1] !== undefined &&
231
+ fileURLToPath(import.meta.url) === realpathSync(process.argv[1])
232
+ ) {
233
+ main();
234
+ }