@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,375 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The staging machinery adopt mode's two stagers share --
5
+ * `baseline-stage.ts` (`.groundwork/baseline/`) and `pack-stage.ts`
6
+ * (`.groundwork/packs/`): the inert `.staged` naming convention, the
7
+ * template-tree walk that maps a source file to its install path, the
8
+ * stale-work-dir sweep, and the atomic write-then-swap lifecycle. Each
9
+ * stager owns its own plan validation and its own result shape; everything
10
+ * here is parameterized by the staging directory's name and a noun for
11
+ * messages, so both stagers' failures read the same way.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
16
+ import { dirname, join, relative, resolve } from "node:path";
17
+ import { restoreDotfilePath } from "./assets.js";
18
+ import { isPathContained } from "./emit.js";
19
+ import { assertNotSymlink } from "./fs-guard.js";
20
+ import { applyTokens } from "./tokens.js";
21
+ // `toPosixPath` lives in assets.ts, so inventory.ts can use it without
22
+ // pulling in this stager; re-exported for this module's existing consumers.
23
+ export { toPosixPath } from "./assets.js";
24
+ /**
25
+ * The suffix every staged file carries, so no extension-based glob
26
+ * (`**\/*.ts`, `**\/*.md`, `vitest.config.*`) ever matches a staged copy.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const staged = `eslint.config.js${STAGED_SUFFIX}`; // "eslint.config.js.staged"
31
+ * ```
32
+ */
33
+ export const STAGED_SUFFIX = ".staged";
34
+ /**
35
+ * The staged name for a project-relative `path`: `path` + {@link STAGED_SUFFIX}.
36
+ * The single derivation every stager and adopt mode's write-scope check use.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * stagedNameFor("src/index.ts"); // "src/index.ts.staged"
41
+ * ```
42
+ */
43
+ export function stagedNameFor(path) {
44
+ return `${path}${STAGED_SUFFIX}`;
45
+ }
46
+ /**
47
+ * Finds the first pair of staged paths that would land on the same file on
48
+ * some supported file system: two paths equal once folded -- NFC-normalized
49
+ * (macOS's APFS treats a precomposed `é` and `e` + U+0301 as one name) and
50
+ * case-folded (macOS and Windows default to case-insensitive) -- or one path
51
+ * a proper directory prefix of another once folded (`x.staged` as both a
52
+ * file and the directory holding `x.staged/y.staged`). Either collision
53
+ * makes the second exclusive (`wx`) write fail mid-staging; a plan checks
54
+ * for it first so the defect surfaces as its own error instead. Folding is
55
+ * `toLowerCase()` after `normalize("NFC")`, not a full Unicode case fold, so
56
+ * it is a best-effort approximation of each file system's own rules. Paths
57
+ * are otherwise compared as given -- pass them `/`-separated
58
+ * ({@link toPosixPath}).
59
+ *
60
+ * @returns A description naming both colliding paths, or `undefined` when
61
+ * none collide.
62
+ *
63
+ * @example
64
+ * ```ts
65
+ * findStagedPathCollision(["README.md.staged", "readme.md.staged"]); // "README.md.staged and readme.md.staged …"
66
+ * findStagedPathCollision(["a.staged", "b.staged"]); // undefined
67
+ * ```
68
+ */
69
+ export function findStagedPathCollision(stagedPaths) {
70
+ const fold = (p) => p.normalize("NFC").toLowerCase();
71
+ const byFolded = new Map();
72
+ for (const path of stagedPaths) {
73
+ const folded = fold(path);
74
+ const previous = byFolded.get(folded);
75
+ if (previous !== undefined) {
76
+ return previous.toLowerCase() === path.toLowerCase()
77
+ ? `${previous} and ${path} differ only by letter case, so they are the same file on a case-insensitive file system`
78
+ : `${previous} and ${path} differ only by Unicode normalization (and possibly letter case), so they are the same file on a normalization-insensitive file system`;
79
+ }
80
+ byFolded.set(folded, path);
81
+ }
82
+ for (const path of stagedPaths) {
83
+ const segments = fold(path).split("/");
84
+ for (let i = 1; i < segments.length; i++) {
85
+ const ancestor = byFolded.get(segments.slice(0, i).join("/"));
86
+ if (ancestor !== undefined) {
87
+ return `${ancestor} would be both a file and the directory holding ${path}`;
88
+ }
89
+ }
90
+ }
91
+ return undefined;
92
+ }
93
+ function collectInto(root, currentDir, tokens, results) {
94
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
95
+ const sourcePath = join(currentDir, entry.name);
96
+ if (entry.isDirectory()) {
97
+ collectInto(root, sourcePath, tokens, results);
98
+ continue;
99
+ }
100
+ const installPath = restoreDotfilePath(applyTokens(relative(root, sourcePath), tokens));
101
+ const previous = results.get(installPath);
102
+ if (previous !== undefined) {
103
+ throw new Error(`template files ${previous} and ${sourcePath} both install to ${installPath} under ${root}; remove one of them`);
104
+ }
105
+ results.set(installPath, sourcePath);
106
+ }
107
+ }
108
+ /**
109
+ * Maps every file under `root` to its install path (tokens applied, dotfile
110
+ * name restored) -- the same derivation `planConflicts` uses -- keyed by
111
+ * install path, valued by absolute source path.
112
+ *
113
+ * @throws `Error` (no `cause`) naming the install path and both sources when
114
+ * two files map to the same install path (`_gitignore` beside `.gitignore`),
115
+ * rather than silently letting the later one win.
116
+ *
117
+ * @example
118
+ * ```ts
119
+ * const files = collectTemplateFiles("/repo/templates/core", { PROJECT_NAME: "acme" });
120
+ * files.get(".gitignore"); // "/repo/templates/core/_gitignore"
121
+ * ```
122
+ */
123
+ export function collectTemplateFiles(root, tokens) {
124
+ const results = new Map();
125
+ collectInto(root, root, tokens, results);
126
+ return results;
127
+ }
128
+ /**
129
+ * `rmSync` options for a staging directory: recursive, tolerant of absence,
130
+ * and retried on a transient EBUSY/EPERM.
131
+ */
132
+ const RM_DIR_OPTIONS = {
133
+ recursive: true,
134
+ force: true,
135
+ maxRetries: 3,
136
+ };
137
+ /**
138
+ * Removes `path` recursively; a failure only warns, naming the path, so it
139
+ * can never shadow the outcome it is cleaning up after.
140
+ */
141
+ function removeBestEffort(path) {
142
+ try {
143
+ rmSync(path, RM_DIR_OPTIONS);
144
+ }
145
+ catch (error) {
146
+ console.warn(`warning: could not remove the temporary staging directory ${path} -- delete it by hand (${error instanceof Error ? error.message : String(error)})`);
147
+ }
148
+ }
149
+ /**
150
+ * The swap's final rename and the restore of the parked previous staging
151
+ * both failed: the parked copy is the only one left. Its `errors` are
152
+ * `[swapError, restoreError]`.
153
+ */
154
+ class ParkedStagingError extends AggregateError {
155
+ }
156
+ /**
157
+ * The standard staging failure: `.groundwork/` is incomplete and the CLI
158
+ * should be re-run. The message embeds the cause's own message so it stands
159
+ * alone; `formatErrorChain` skips the then-redundant `caused by:` line.
160
+ */
161
+ function incompleteStagingError(noun, destDir, cause) {
162
+ const reason = cause instanceof Error ? cause.message : String(cause);
163
+ return new Error(`staging the ${noun} into ${destDir} failed (${reason}), so .groundwork/ is incomplete -- fix the cause and re-run the CLI`, { cause });
164
+ }
165
+ /**
166
+ * Removes every entry of `groundworkDir` whose name starts with
167
+ * `.<dirName>-`, best effort (a removal failure only warns). Its purpose is
168
+ * reclaiming work directories a crashed earlier run left behind, but there
169
+ * is no PID or age check: any matching entry is removed, whatever made it,
170
+ * because `.groundwork/` is CLI-owned. So two concurrent runs against the
171
+ * same directory can delete each other's in-progress work directory; the
172
+ * run that loses fails with the generic {@link incompleteStagingError}
173
+ * ("`.groundwork/` is incomplete -- re-run the CLI"). Nothing else in
174
+ * `groundworkDir` is touched; a missing `groundworkDir` is a no-op. Failing
175
+ * to list `groundworkDir` at all throws {@link incompleteStagingError}.
176
+ */
177
+ function removeStaleWorkDirs(target) {
178
+ const { groundworkDir, dirName, noun } = target;
179
+ if (!existsSync(groundworkDir)) {
180
+ return;
181
+ }
182
+ let names;
183
+ try {
184
+ names = readdirSync(groundworkDir);
185
+ }
186
+ catch (cause) {
187
+ throw incompleteStagingError(noun, join(groundworkDir, dirName), cause);
188
+ }
189
+ const prefix = `.${dirName}-`;
190
+ for (const name of names) {
191
+ if (name.startsWith(prefix)) {
192
+ removeBestEffort(join(groundworkDir, name));
193
+ }
194
+ }
195
+ }
196
+ /**
197
+ * Moves `newDir` into place at `destDir`, parking any previous `destDir` at
198
+ * `parkedDir` first. If the final rename fails the parked copy is renamed
199
+ * back and the rename failure rethrown; if that restore fails too, throws a
200
+ * {@link ParkedStagingError} carrying both errors, naming `parkedDir`, and
201
+ * telling the user to re-run the CLI -- the staging is derived data, so the
202
+ * next run's stale-dir sweep removes the parked copy and regenerates
203
+ * `destDir`.
204
+ */
205
+ function swapInto(target, newDir, destDir, parkedDir) {
206
+ const { noun } = target;
207
+ const was = target.plural ? "were" : "was";
208
+ const hadPrevious = existsSync(destDir);
209
+ if (hadPrevious) {
210
+ renameSync(destDir, parkedDir);
211
+ }
212
+ try {
213
+ renameSync(newDir, destDir);
214
+ }
215
+ catch (error) {
216
+ if (!hadPrevious) {
217
+ throw error;
218
+ }
219
+ try {
220
+ renameSync(parkedDir, destDir);
221
+ }
222
+ catch (restoreError) {
223
+ throw new ParkedStagingError([error, restoreError], `moving the new ${noun} into ${destDir} failed and restoring the previous one failed too; the previous ${noun} ${was} parked at ${parkedDir} -- fix the cause and re-run the CLI: the staging is derived data, regenerated from the template, and the next run removes the parked copy`);
224
+ }
225
+ throw error;
226
+ }
227
+ }
228
+ /**
229
+ * Refuses a staging plan computed for a different `.groundwork/` than the
230
+ * one a stager was handed: the plan's scope-checked paths would otherwise
231
+ * not be the paths written. Directories are compared after `path.resolve`,
232
+ * so two spellings of one directory match. A caller defect, so a plain
233
+ * `Error` (never an `AssertionError`), thrown before anything is written.
234
+ *
235
+ * @throws `Error` naming both directories when they differ.
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * assertPlanBuiltFor("stagePacks", plan.groundworkDir, groundworkDir);
240
+ * ```
241
+ */
242
+ export function assertPlanBuiltFor(caller, planGroundworkDir, groundworkDir) {
243
+ const planned = resolve(planGroundworkDir);
244
+ const actual = resolve(groundworkDir);
245
+ if (planned !== actual) {
246
+ throw new Error(`${caller}: the plan was built for ${planned}, not ${actual} -- compute the plan for the same .groundwork/ directory it is staged into`);
247
+ }
248
+ }
249
+ /**
250
+ * The first step of every staging run: refuses a symlinked `groundworkDir`
251
+ * or `<groundworkDir>/<dirName>` (before anything is deleted or written),
252
+ * then sweeps the `.<dirName>-*` work directories a crashed earlier run
253
+ * left. The sweep removes **every** entry of the CLI-owned `.groundwork/`
254
+ * whose name matches `.<dirName>-*`, whatever created it -- including a
255
+ * concurrent run's in-progress work directory, so two runs against the same
256
+ * directory can fail each other with the standard incomplete/re-run error;
257
+ * a failure to remove one only warns. Returns the staging directory's path.
258
+ *
259
+ * @throws `Error` naming the path when either directory is a symlink; the
260
+ * standard `.groundwork/ is incomplete -- re-run` `Error`, with `cause`, when
261
+ * `groundworkDir` cannot be listed for the sweep.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * const destDir = prepareStaging({ groundworkDir, dirName: "packs", noun: "packs", plural: true });
266
+ * ```
267
+ */
268
+ export function prepareStaging(target) {
269
+ const destDir = join(target.groundworkDir, target.dirName);
270
+ assertNotSymlink(target.groundworkDir);
271
+ assertNotSymlink(destDir);
272
+ removeStaleWorkDirs(target);
273
+ return destDir;
274
+ }
275
+ /**
276
+ * Removes the staging directory outright -- what a run with nothing to
277
+ * stage does, so no previous staging outlives it.
278
+ *
279
+ * @throws {@link incompleteStagingError}, with the removal failure as `cause`.
280
+ *
281
+ * @example
282
+ * ```ts
283
+ * clearStaging({ groundworkDir, dirName: "packs", noun: "packs" });
284
+ * ```
285
+ */
286
+ export function clearStaging(target) {
287
+ const destDir = join(target.groundworkDir, target.dirName);
288
+ try {
289
+ rmSync(destDir, RM_DIR_OPTIONS);
290
+ }
291
+ catch (cause) {
292
+ throw incompleteStagingError(target.noun, destDir, cause);
293
+ }
294
+ }
295
+ /**
296
+ * Writes `bytes` to `<rootDir>/<stagedName>` with the exclusive `wx` flag
297
+ * (creating parent directories) and returns their lowercase hex sha256.
298
+ * Re-asserts the CWE-22 containment invariant (docs/assurance-case.md)
299
+ * against the directory actually written to; the caller's plan must already
300
+ * have refused an escaping name.
301
+ *
302
+ * @throws `AssertionError` (message prefixed by `caller`) when the staged
303
+ * path escapes `rootDir`; any write error unchanged.
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * const sha256 = writeStagedBytes("stagePacks", bytes, newDir, "pack.json.staged");
308
+ * ```
309
+ */
310
+ export function writeStagedBytes(caller, bytes, rootDir, stagedName) {
311
+ const destPath = join(rootDir, stagedName);
312
+ assert.ok(isPathContained(destPath, rootDir), `${caller}: staged path ${resolve(destPath)} escapes ${resolve(rootDir)}`);
313
+ mkdirSync(dirname(destPath), { recursive: true });
314
+ writeFileSync(destPath, bytes, { flag: "wx" });
315
+ return createHash("sha256").update(bytes).digest("hex");
316
+ }
317
+ /**
318
+ * Runs `write` against a fresh `<groundworkDir>/.<dirName>-XXXXXX/<dirName>`
319
+ * directory and, only once it returns, swaps that directory over
320
+ * `<groundworkDir>/<dirName>` by rename -- so a failure part-way through
321
+ * leaves any previous staging intact, and a previous staging is replaced
322
+ * wholesale. Call {@link prepareStaging} first.
323
+ *
324
+ * - If the swap's final rename fails, the previous staging is renamed back.
325
+ * Only if that restore also fails is the staging directory left absent:
326
+ * the previous staging then survives, parked inside the work directory,
327
+ * which is deliberately not removed (a later run's stale-dir sweep does).
328
+ * - The work directory is otherwise always removed; a failure to remove it
329
+ * only warns, naming its path.
330
+ *
331
+ * @throws {@link ParkedStagingError} when both renames fail; an
332
+ * `AssertionError` unwrapped (a broken invariant is a bug, not something a
333
+ * re-run fixes); otherwise {@link incompleteStagingError} with `cause`.
334
+ *
335
+ * @example
336
+ * ```ts
337
+ * const files = stageAtomically(target, (newDir) =>
338
+ * [writeStagedBytes("stagePacks", bytes, newDir, "a.txt.staged")],
339
+ * );
340
+ * ```
341
+ */
342
+ export function stageAtomically(target, write) {
343
+ const destDir = join(target.groundworkDir, target.dirName);
344
+ let workDir;
345
+ // Set only when the previous staging is parked inside workDir and could
346
+ // not be restored: workDir then holds its only copy and must survive.
347
+ let keepWorkDir = false;
348
+ try {
349
+ mkdirSync(target.groundworkDir, { recursive: true });
350
+ workDir = mkdtempSync(join(target.groundworkDir, `.${target.dirName}-`));
351
+ const newDir = join(workDir, target.dirName);
352
+ mkdirSync(newDir);
353
+ const result = write(newDir);
354
+ swapInto(target, newDir, destDir, join(workDir, "previous"));
355
+ return result;
356
+ }
357
+ catch (cause) {
358
+ if (cause instanceof ParkedStagingError) {
359
+ keepWorkDir = true;
360
+ throw cause;
361
+ }
362
+ if (cause instanceof assert.AssertionError) {
363
+ // A broken CWE-22 invariant is a bug in the stager, not a transient
364
+ // failure a re-run could fix: surface it as itself.
365
+ throw cause;
366
+ }
367
+ throw incompleteStagingError(target.noun, destDir, cause);
368
+ }
369
+ finally {
370
+ if (workDir !== undefined && !keepWorkDir) {
371
+ removeBestEffort(workDir);
372
+ }
373
+ }
374
+ }
375
+ //# sourceMappingURL=staging.js.map
@@ -8,7 +8,39 @@ export interface WalkEntry {
8
8
  /**
9
9
  * Recursively lists `root`, skipping known dependency/build directories and
10
10
  * stopping once a descendant is more than `maxDepth` directories below
11
- * `root`. Missing or unreadable directories are skipped, not thrown.
11
+ * `root`. A missing directory (`ENOENT`/`ENOTDIR`, the same absent set
12
+ * `guardedExists` uses) is skipped silently. An unreadable or unresolvable
13
+ * one (`EACCES`/`EPERM`, or a symlink loop's `ELOOP`) is skipped too, but
14
+ * recorded in `undetermined`. Any other listing
15
+ * failure (`EIO`, `EMFILE`, ...) throws an `Error` naming the directory, with
16
+ * the original as `cause`. For the survey collectors; the graders use
17
+ * {@link walkBoundedForGrading} instead.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const undetermined: string[] = [];
22
+ * const markdown = walkBounded("/path/to/project", 2, undetermined).filter(
23
+ * (entry) => !entry.isDirectory && entry.relPath.endsWith(".md"),
24
+ * );
25
+ * ```
12
26
  */
13
- export declare function walkBounded(root: string, maxDepth: number): WalkEntry[];
27
+ export declare function walkBounded(root: string, maxDepth: number, undetermined: string[]): WalkEntry[];
28
+ /**
29
+ * The same bounded listing as {@link walkBounded}, but a directory whose
30
+ * listing fails for ANY reason is skipped silently, never thrown. For the
31
+ * harness and toolchain graders (`harness/grade.ts`, `toolchain/grade.ts`)
32
+ * only: a grader never throws, and its emitted `.mjs` twins
33
+ * (`templates/core/bin/lib/{harness,toolchain}-rules.mjs`) swallow every
34
+ * listing failure in their own `walkBounded` -- this keeps both sides' grades
35
+ * identical for the same tree. The survey keeps {@link walkBounded}'s errno
36
+ * discrimination.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const files = walkBoundedForGrading("/path/to/project", 4).filter(
41
+ * (entry) => !entry.isDirectory,
42
+ * );
43
+ * ```
44
+ */
45
+ export declare function walkBoundedForGrading(root: string, maxDepth: number): WalkEntry[];
14
46
  //# sourceMappingURL=fs-walk.d.ts.map
@@ -1,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * A bounded directory walk shared by every survey collector. Adopt mode may
3
5
  * run against an arbitrarily large pre-existing repository (dependency
@@ -7,6 +9,7 @@
7
9
  */
8
10
  import { readdirSync } from "node:fs";
9
11
  import { join, relative } from "node:path";
12
+ import { isAbsentError, readFailure, unreadableNote, unresolvableCode, } from "./internal/read-guard.js";
10
13
  const SKIP_DIR_NAMES = new Set([
11
14
  "node_modules",
12
15
  ".git",
@@ -20,12 +23,17 @@ const SKIP_DIR_NAMES = new Set([
20
23
  "out",
21
24
  ".nx",
22
25
  ]);
26
+ // Claude Code creates git worktrees at `.claude/worktrees/<name>/` -- each a
27
+ // full second checkout that must not be walked twice. Matched as an exact
28
+ // path relative to the walk root, never by bare name: a directory literally
29
+ // named `worktrees` elsewhere (`src/worktrees/`, `.claude/skills/worktrees/`)
30
+ // is real project content and must stay visible to every survey.
31
+ const SKIP_REL_DIR_PATHS = new Set([".claude/worktrees"]);
23
32
  /**
24
- * Recursively lists `root`, skipping known dependency/build directories and
25
- * stopping once a descendant is more than `maxDepth` directories below
26
- * `root`. Missing or unreadable directories are skipped, not thrown.
33
+ * The shared bounded walk. `onListFailure` decides what a failed directory
34
+ * listing means: it returns normally to skip the directory, or throws.
27
35
  */
28
- export function walkBounded(root, maxDepth) {
36
+ function walk(root, maxDepth, onListFailure) {
29
37
  const results = [];
30
38
  const visit = (dir, depth) => {
31
39
  if (depth > maxDepth) {
@@ -35,7 +43,8 @@ export function walkBounded(root, maxDepth) {
35
43
  try {
36
44
  entries = readdirSync(dir, { withFileTypes: true });
37
45
  }
38
- catch {
46
+ catch (error) {
47
+ onListFailure(dir, error);
39
48
  return;
40
49
  }
41
50
  for (const entry of entries) {
@@ -44,6 +53,9 @@ export function walkBounded(root, maxDepth) {
44
53
  }
45
54
  const absPath = join(dir, entry.name);
46
55
  const relPath = relative(root, absPath).split("\\").join("/");
56
+ if (entry.isDirectory() && SKIP_REL_DIR_PATHS.has(relPath)) {
57
+ continue;
58
+ }
47
59
  results.push({
48
60
  path: absPath,
49
61
  relPath,
@@ -57,4 +69,57 @@ export function walkBounded(root, maxDepth) {
57
69
  visit(root, 0);
58
70
  return results;
59
71
  }
72
+ /**
73
+ * Recursively lists `root`, skipping known dependency/build directories and
74
+ * stopping once a descendant is more than `maxDepth` directories below
75
+ * `root`. A missing directory (`ENOENT`/`ENOTDIR`, the same absent set
76
+ * `guardedExists` uses) is skipped silently. An unreadable or unresolvable
77
+ * one (`EACCES`/`EPERM`, or a symlink loop's `ELOOP`) is skipped too, but
78
+ * recorded in `undetermined`. Any other listing
79
+ * failure (`EIO`, `EMFILE`, ...) throws an `Error` naming the directory, with
80
+ * the original as `cause`. For the survey collectors; the graders use
81
+ * {@link walkBoundedForGrading} instead.
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const undetermined: string[] = [];
86
+ * const markdown = walkBounded("/path/to/project", 2, undetermined).filter(
87
+ * (entry) => !entry.isDirectory && entry.relPath.endsWith(".md"),
88
+ * );
89
+ * ```
90
+ */
91
+ export function walkBounded(root, maxDepth, undetermined) {
92
+ return walk(root, maxDepth, (dir, error) => {
93
+ const code = unresolvableCode(error);
94
+ if (code !== undefined) {
95
+ undetermined.push(unreadableNote(dir, code));
96
+ return;
97
+ }
98
+ if (isAbsentError(error))
99
+ return;
100
+ throw readFailure(dir, error);
101
+ });
102
+ }
103
+ /**
104
+ * The same bounded listing as {@link walkBounded}, but a directory whose
105
+ * listing fails for ANY reason is skipped silently, never thrown. For the
106
+ * harness and toolchain graders (`harness/grade.ts`, `toolchain/grade.ts`)
107
+ * only: a grader never throws, and its emitted `.mjs` twins
108
+ * (`templates/core/bin/lib/{harness,toolchain}-rules.mjs`) swallow every
109
+ * listing failure in their own `walkBounded` -- this keeps both sides' grades
110
+ * identical for the same tree. The survey keeps {@link walkBounded}'s errno
111
+ * discrimination.
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * const files = walkBoundedForGrading("/path/to/project", 4).filter(
116
+ * (entry) => !entry.isDirectory,
117
+ * );
118
+ * ```
119
+ */
120
+ export function walkBoundedForGrading(root, maxDepth) {
121
+ return walk(root, maxDepth, () => {
122
+ // Deliberately swallowed: parity with the emitted twins' bare `catch`.
123
+ });
124
+ }
60
125
  //# sourceMappingURL=fs-walk.js.map
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Why a path `probePath` reported `absent` is not really absent, or
3
+ * `undefined` when nothing is there at all. A symlink at the path itself is
4
+ * named as dangling whenever `stat` through it failed -- its target missing
5
+ * (`ENOENT`) or resolving through a regular file (`ENOTDIR`) alike, since
6
+ * `lstat` finding a link is all this checks. A dangling symlink at an
7
+ * ancestor below `root` is named the same way, but only for a missing target
8
+ * (`ENOENT`); an ancestor link resolving through a regular file surfaces as
9
+ * `ENOTDIR` instead, named by the nearest non-directory ancestor that can be
10
+ * established, else the path itself. A regular file blocking an
11
+ * ancestor is named with `ENOTDIR`; an `lstat` that succeeds on a
12
+ * non-symlink right after `stat` said absent means the tree changed during
13
+ * the survey, and the path is recorded unreadable (`ENOENT`). Any other
14
+ * `lstat` errno throws a `SurveyReadError` naming the path, with the
15
+ * original as `cause`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const probe = probePath(targetPath);
20
+ * if (probe.kind === "absent") {
21
+ * const note = blockedAbsentNote(targetPath, targetDir);
22
+ * if (note !== undefined) undetermined.push(note);
23
+ * }
24
+ * ```
25
+ */
26
+ export declare function blockedAbsentNote(path: string, root: string): string | undefined;
27
+ //# sourceMappingURL=blocked-path.d.ts.map
@@ -0,0 +1,129 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Tells a genuinely absent path apart from one `probePath` merely folded into
5
+ * "absent". `probePath` answers `absent` for `ENOENT`/`ENOTDIR` -- correct for
6
+ * the survey's own exists-probes -- but that also covers a dangling symlink at
7
+ * the path or at one of its ancestors (`ENOENT`) and a regular file where an
8
+ * ancestor directory should be (`ENOTDIR`). A plan (`conflicts.ts`) or a
9
+ * wiring observation (`packs.ts`) must never read either as "nothing there":
10
+ * both are something in the project's tree a write would have to go through.
11
+ * Shared so the two modules record the same note for the same path.
12
+ */
13
+ import { lstatSync, statSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+ import { errnoCode, readFailure, unreadableNote } from "./read-guard.js";
16
+ // "its target does not exist" reads loosely for a link at the path itself
17
+ // whose target resolves through a regular file (`ENOTDIR`) -- that target
18
+ // does not exist as a reachable path either. Wording kept as-is: tests
19
+ // match on "dangling symlink".
20
+ function danglingNote(path) {
21
+ return `${path} is a dangling symlink -- its target does not exist, so its contents are not in this survey`;
22
+ }
23
+ /**
24
+ * The nearest ancestor of `path`, walking up no further than `root`, that
25
+ * exists and is not a directory -- the component that made a `stat` of
26
+ * `path` fail with `ENOTDIR` -- or `undefined` if none can be established
27
+ * (the tree changed underneath, or the ancestor cannot itself be stat'd).
28
+ */
29
+ function blockedAncestor(path, root) {
30
+ let current = dirname(path);
31
+ for (;;) {
32
+ try {
33
+ if (!statSync(current).isDirectory())
34
+ return current;
35
+ }
36
+ catch {
37
+ // This component is itself absent or unreachable: it is not the file
38
+ // blocking the path, so keep walking up. Best effort -- the caller
39
+ // still records the entry with `ENOTDIR` on the path itself.
40
+ }
41
+ const parent = dirname(current);
42
+ if (current === root || parent === current)
43
+ return undefined;
44
+ current = parent;
45
+ }
46
+ }
47
+ /**
48
+ * The nearest strict ancestor of `path`, below `root`, that is a symlink
49
+ * whose target does not exist -- the component that made an `lstat` of
50
+ * `path` fail with `ENOENT` -- or `undefined` when every ancestor that
51
+ * exists resolves (the path is genuinely absent).
52
+ */
53
+ function danglingAncestor(path, root) {
54
+ for (let current = dirname(path); current !== root && dirname(current) !== current; current = dirname(current)) {
55
+ if (isDanglingSymlink(current))
56
+ return current;
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * Whether `path` itself is a symlink whose target does not exist (`lstat`
62
+ * finds a link, `stat` through it fails `ENOENT`). Best effort: the caller
63
+ * only walks here after an `lstat` of a descendant already traversed every
64
+ * existing ancestor, so an ancestor this cannot classify is not the dangling
65
+ * one and answers `false`.
66
+ */
67
+ function isDanglingSymlink(path) {
68
+ try {
69
+ if (!lstatSync(path).isSymbolicLink())
70
+ return false;
71
+ }
72
+ catch {
73
+ // Absent or unclassifiable at the link itself: not a dangling symlink.
74
+ return false;
75
+ }
76
+ try {
77
+ statSync(path);
78
+ return false;
79
+ }
80
+ catch (error) {
81
+ return errnoCode(error) === "ENOENT";
82
+ }
83
+ }
84
+ /**
85
+ * Why a path `probePath` reported `absent` is not really absent, or
86
+ * `undefined` when nothing is there at all. A symlink at the path itself is
87
+ * named as dangling whenever `stat` through it failed -- its target missing
88
+ * (`ENOENT`) or resolving through a regular file (`ENOTDIR`) alike, since
89
+ * `lstat` finding a link is all this checks. A dangling symlink at an
90
+ * ancestor below `root` is named the same way, but only for a missing target
91
+ * (`ENOENT`); an ancestor link resolving through a regular file surfaces as
92
+ * `ENOTDIR` instead, named by the nearest non-directory ancestor that can be
93
+ * established, else the path itself. A regular file blocking an
94
+ * ancestor is named with `ENOTDIR`; an `lstat` that succeeds on a
95
+ * non-symlink right after `stat` said absent means the tree changed during
96
+ * the survey, and the path is recorded unreadable (`ENOENT`). Any other
97
+ * `lstat` errno throws a `SurveyReadError` naming the path, with the
98
+ * original as `cause`.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const probe = probePath(targetPath);
103
+ * if (probe.kind === "absent") {
104
+ * const note = blockedAbsentNote(targetPath, targetDir);
105
+ * if (note !== undefined) undetermined.push(note);
106
+ * }
107
+ * ```
108
+ */
109
+ export function blockedAbsentNote(path, root) {
110
+ let isSymlink;
111
+ try {
112
+ isSymlink = lstatSync(path).isSymbolicLink();
113
+ }
114
+ catch (error) {
115
+ const code = errnoCode(error);
116
+ if (code === "ENOTDIR") {
117
+ return unreadableNote(blockedAncestor(path, root) ?? path, "ENOTDIR");
118
+ }
119
+ if (code !== "ENOENT")
120
+ throw readFailure(path, error);
121
+ const ancestor = danglingAncestor(path, root);
122
+ return ancestor === undefined ? undefined : danglingNote(ancestor);
123
+ }
124
+ // `lstat` succeeded after `stat` said absent. A symlink means `stat` could
125
+ // not resolve through it (`ENOENT` or `ENOTDIR`): dangling. A non-symlink
126
+ // means the tree changed during the survey: present, contents unknown.
127
+ return isSymlink ? danglingNote(path) : unreadableNote(path, "ENOENT");
128
+ }
129
+ //# sourceMappingURL=blocked-path.js.map