@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
@@ -6,9 +6,41 @@
6
6
  * YAML or JavaScript. Every merge is append-only (it never rebuilds an
7
7
  * object wholesale, which would risk reordering keys Prettier would
8
8
  * otherwise preserve) and idempotent (merging the same fragment twice
9
- * produces the same result as merging it once).
9
+ * produces the same result as merging it once). Every key taken from a
10
+ * caller fragment is checked against `__proto__`/`constructor`/`prototype`
11
+ * ({@link isPrototypeSensitiveKey}) before it is read or written, and such a
12
+ * key is rejected with a thrown error rather than merged. For a pack's wiring
13
+ * this merge-time check is defence in depth: `packs.ts`'s `loadPack` already
14
+ * refuses the same keys in `wiring.settings`, `wiring.settingsTopLevel` and
15
+ * `wiring.packageScripts` before fresh mode writes anything and before adopt
16
+ * mode touches `.groundwork/`. Adopt mode's CLI never calls these merges at
17
+ * all -- `/customize` applies a staged pack's wiring by hand, so the guard
18
+ * that covers that path is `loadPack`'s refusal to stage such a pack, not
19
+ * this module. The hazard this closes is local, not global: assigning
20
+ * `obj["__proto__"] = v` on the spread-copied plain objects these merges build
21
+ * swaps _that object's own_ prototype instead of creating an own key, so
22
+ * `JSON.stringify` later drops the key without a word. It never reached the
23
+ * shared `Object.prototype`; CWE-1321 is cited only because that is how the
24
+ * weakness is catalogued.
10
25
  */
11
26
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
27
+ /**
28
+ * True when `key` is exactly `__proto__`, `constructor` or `prototype` -- the
29
+ * names every merge in this module refuses to take from a caller fragment.
30
+ * This is the single source of truth for that list: `packs.ts`'s `loadPack`
31
+ * calls it to reject such a key in a pack's wiring before either CLI mode
32
+ * writes anything, and this module's own merges check the same list again at
33
+ * merge time as defence in depth. The match is exact and case-sensitive.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * import { isPrototypeSensitiveKey } from "./merge-json.js";
38
+ *
39
+ * isPrototypeSensitiveKey("__proto__"); // true
40
+ * isPrototypeSensitiveKey("statusLine"); // false
41
+ * ```
42
+ */
43
+ export declare function isPrototypeSensitiveKey(key: string): boolean;
12
44
  interface SettingsHookCommand {
13
45
  type: string;
14
46
  command: string;
@@ -27,7 +59,10 @@ export type SettingsHooksFragment = Record<string, SettingsHookEntry[]>;
27
59
  * entry, only `hooks` commands not already present (compared by exact
28
60
  * `command` string) are appended. A command that matches on `command` but
29
61
  * differs in its other fields (`if`, `timeout`) is a hard collision, never
30
- * a silent overwrite.
62
+ * a silent overwrite. An event name of `__proto__`, `constructor` or
63
+ * `prototype` throws: the check runs per key inside the loop, before that
64
+ * key is read or written, and nothing escapes the failed merge -- `existing`
65
+ * is never mutated and no partial result is returned.
31
66
  */
32
67
  export declare function mergeSettingsHooks(existing: unknown, fragment: SettingsHooksFragment): Record<string, unknown>;
33
68
  export type SettingsTopLevelFragment = Record<string, unknown>;
@@ -39,7 +74,11 @@ export type SettingsTopLevelFragment = Record<string, unknown>;
39
74
  * silently clobbering it. A key not yet present is appended; one already
40
75
  * present with an identical value is a no-op; one present with a different
41
76
  * value is a hard collision, never a silent overwrite -- an adopted project's
42
- * own `statusLine` is the user's to replace deliberately.
77
+ * own `statusLine` is the user's to replace deliberately. A fragment key of
78
+ * `__proto__`, `constructor` or `prototype` throws: the check runs per key
79
+ * inside the loop, before that key is read or written, and nothing escapes the
80
+ * failed merge -- `existing` is never mutated and no partial result is
81
+ * returned.
43
82
  */
44
83
  export declare function mergeSettingsTopLevel(existing: unknown, fragment: SettingsTopLevelFragment): Record<string, unknown>;
45
84
  interface ScriptCollision {
@@ -55,7 +94,11 @@ export interface MergePackageScriptsResult {
55
94
  * Merges a pack's `package.json` script additions into the existing
56
95
  * `scripts` block. Never overwrites a differing existing script -- the
57
96
  * collision is returned for the caller to report, consistent with adopt
58
- * mode's "report, then the user decides per conflict" policy.
97
+ * mode's "report, then the user decides per conflict" policy. An addition
98
+ * named `__proto__`, `constructor` or `prototype` is not a collision: it
99
+ * throws. The check runs per key inside the loop, before that key is read or
100
+ * written, and nothing escapes the failed merge -- `existing` is never mutated
101
+ * and no partial result is returned.
59
102
  */
60
103
  export declare function mergePackageScripts(existing: Record<string, string> | undefined, additions: Record<string, string>): MergePackageScriptsResult;
61
104
  export interface VerifyStepAddition {
@@ -1,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Pure, deterministic merges over parsed JSON -- the entire mechanism that
3
5
  * lets a pack extend `.claude/settings.json` (its `hooks` block and a few
@@ -6,11 +8,102 @@
6
8
  * YAML or JavaScript. Every merge is append-only (it never rebuilds an
7
9
  * object wholesale, which would risk reordering keys Prettier would
8
10
  * otherwise preserve) and idempotent (merging the same fragment twice
9
- * produces the same result as merging it once).
11
+ * produces the same result as merging it once). Every key taken from a
12
+ * caller fragment is checked against `__proto__`/`constructor`/`prototype`
13
+ * ({@link isPrototypeSensitiveKey}) before it is read or written, and such a
14
+ * key is rejected with a thrown error rather than merged. For a pack's wiring
15
+ * this merge-time check is defence in depth: `packs.ts`'s `loadPack` already
16
+ * refuses the same keys in `wiring.settings`, `wiring.settingsTopLevel` and
17
+ * `wiring.packageScripts` before fresh mode writes anything and before adopt
18
+ * mode touches `.groundwork/`. Adopt mode's CLI never calls these merges at
19
+ * all -- `/customize` applies a staged pack's wiring by hand, so the guard
20
+ * that covers that path is `loadPack`'s refusal to stage such a pack, not
21
+ * this module. The hazard this closes is local, not global: assigning
22
+ * `obj["__proto__"] = v` on the spread-copied plain objects these merges build
23
+ * swaps _that object's own_ prototype instead of creating an own key, so
24
+ * `JSON.stringify` later drops the key without a word. It never reached the
25
+ * shared `Object.prototype`; CWE-1321 is cited only because that is how the
26
+ * weakness is catalogued.
10
27
  */
11
28
  export function isRecord(value) {
12
29
  return typeof value === "object" && value !== null && !Array.isArray(value);
13
30
  }
31
+ /**
32
+ * Structural equality over parsed JSON: leaves by `Object.is`, arrays by
33
+ * length and per-index recursion, plain objects by the same key set (in any
34
+ * order) and per-key recursion. Unlike comparing `JSON.stringify` output, two
35
+ * objects whose keys were merely inserted in a different order are equal --
36
+ * which is what separates an idempotent re-merge from a real collision.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * deepEqual({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
41
+ * deepEqual([1, 2], [2, 1]); // false
42
+ * ```
43
+ */
44
+ function deepEqual(a, b) {
45
+ if (Array.isArray(a) || Array.isArray(b)) {
46
+ return (Array.isArray(a) &&
47
+ Array.isArray(b) &&
48
+ a.length === b.length &&
49
+ a.every((item, index) => deepEqual(item, b[index])));
50
+ }
51
+ if (isRecord(a) && isRecord(b)) {
52
+ const aKeys = Object.keys(a);
53
+ return (aKeys.length === Object.keys(b).length &&
54
+ aKeys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key])));
55
+ }
56
+ return Object.is(a, b);
57
+ }
58
+ /**
59
+ * Key names {@link isPrototypeSensitiveKey} matches and {@link assertSafeKey}
60
+ * rejects. A three-name denylist is complete here, not an allowlist, because
61
+ * every key comes from a caller fragment merged onto a plain object, where
62
+ * `__proto__` is the only key with special setter behaviour; `constructor`
63
+ * and `prototype` are defence in depth.
64
+ */
65
+ const PROTOTYPE_KEYS = new Set([
66
+ "__proto__",
67
+ "constructor",
68
+ "prototype",
69
+ ]);
70
+ /**
71
+ * True when `key` is exactly `__proto__`, `constructor` or `prototype` -- the
72
+ * names every merge in this module refuses to take from a caller fragment.
73
+ * This is the single source of truth for that list: `packs.ts`'s `loadPack`
74
+ * calls it to reject such a key in a pack's wiring before either CLI mode
75
+ * writes anything, and this module's own merges check the same list again at
76
+ * merge time as defence in depth. The match is exact and case-sensitive.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * import { isPrototypeSensitiveKey } from "./merge-json.js";
81
+ *
82
+ * isPrototypeSensitiveKey("__proto__"); // true
83
+ * isPrototypeSensitiveKey("statusLine"); // false
84
+ * ```
85
+ */
86
+ export function isPrototypeSensitiveKey(key) {
87
+ return PROTOTYPE_KEYS.has(key);
88
+ }
89
+ /**
90
+ * Throws when `key` (taken from a caller fragment) is one of the
91
+ * prototype-sensitive names {@link isPrototypeSensitiveKey} matches. `context`
92
+ * names the merge in the error message.
93
+ *
94
+ * Only `__proto__` is actually exploitable here: on a plain object,
95
+ * `obj["__proto__"] = v` invokes the inherited setter, swapping that one
96
+ * object's prototype rather than storing an own key, so the value is silently
97
+ * dropped by `JSON.stringify` (the shape CWE-1321 catalogues, though nothing
98
+ * global is polluted). `constructor` and `prototype` assign as ordinary own
99
+ * keys on a plain object; they are rejected anyway as cheap defence in depth,
100
+ * since no shipped pack uses either name.
101
+ */
102
+ function assertSafeKey(key, context) {
103
+ if (isPrototypeSensitiveKey(key)) {
104
+ throw new Error(`${context} merge: refusing prototype-sensitive key "${key}"`);
105
+ }
106
+ }
14
107
  /**
15
108
  * Merges a pack's `.claude/settings.json` hook fragment into an existing
16
109
  * settings object. Per event, an entry is matched by `matcher` (both absent
@@ -18,7 +111,10 @@ export function isRecord(value) {
18
111
  * entry, only `hooks` commands not already present (compared by exact
19
112
  * `command` string) are appended. A command that matches on `command` but
20
113
  * differs in its other fields (`if`, `timeout`) is a hard collision, never
21
- * a silent overwrite.
114
+ * a silent overwrite. An event name of `__proto__`, `constructor` or
115
+ * `prototype` throws: the check runs per key inside the loop, before that
116
+ * key is read or written, and nothing escapes the failed merge -- `existing`
117
+ * is never mutated and no partial result is returned.
22
118
  */
23
119
  export function mergeSettingsHooks(existing, fragment) {
24
120
  const settings = isRecord(existing)
@@ -29,6 +125,7 @@ export function mergeSettingsHooks(existing, fragment) {
29
125
  ? { ...existingHooks }
30
126
  : {};
31
127
  for (const [event, entries] of Object.entries(fragment)) {
128
+ assertSafeKey(event, "settings.json hooks");
32
129
  const existingEntries = Array.isArray(hooks[event])
33
130
  ? [...hooks[event]]
34
131
  : [];
@@ -46,7 +143,7 @@ export function mergeSettingsHooks(existing, fragment) {
46
143
  for (const hookCmd of entry.hooks) {
47
144
  const duplicate = mergedHooks.find((candidate) => candidate.command === hookCmd.command);
48
145
  if (duplicate) {
49
- if (JSON.stringify(duplicate) !== JSON.stringify(hookCmd)) {
146
+ if (!deepEqual(duplicate, hookCmd)) {
50
147
  throw new Error(`settings.json merge collision: "${event}" (matcher ${JSON.stringify(entry.matcher)}) already has a hook for "${hookCmd.command}" with different config`);
51
148
  }
52
149
  continue; // Identical entry already present -- idempotent no-op.
@@ -67,13 +164,18 @@ export function mergeSettingsHooks(existing, fragment) {
67
164
  * silently clobbering it. A key not yet present is appended; one already
68
165
  * present with an identical value is a no-op; one present with a different
69
166
  * value is a hard collision, never a silent overwrite -- an adopted project's
70
- * own `statusLine` is the user's to replace deliberately.
167
+ * own `statusLine` is the user's to replace deliberately. A fragment key of
168
+ * `__proto__`, `constructor` or `prototype` throws: the check runs per key
169
+ * inside the loop, before that key is read or written, and nothing escapes the
170
+ * failed merge -- `existing` is never mutated and no partial result is
171
+ * returned.
71
172
  */
72
173
  export function mergeSettingsTopLevel(existing, fragment) {
73
174
  const settings = isRecord(existing)
74
175
  ? { ...existing }
75
176
  : {};
76
177
  for (const [key, value] of Object.entries(fragment)) {
178
+ assertSafeKey(key, "settings.json");
77
179
  if (key === "hooks") {
78
180
  throw new Error('settings.json merge: "hooks" is owned by mergeSettingsHooks and cannot be set as a top-level key');
79
181
  }
@@ -81,7 +183,7 @@ export function mergeSettingsTopLevel(existing, fragment) {
81
183
  settings[key] = value;
82
184
  continue;
83
185
  }
84
- if (JSON.stringify(settings[key]) !== JSON.stringify(value)) {
186
+ if (!deepEqual(settings[key], value)) {
85
187
  throw new Error(`settings.json merge collision: "${key}" is already set with a different value`);
86
188
  }
87
189
  // Identical value already present -- idempotent no-op.
@@ -92,13 +194,23 @@ export function mergeSettingsTopLevel(existing, fragment) {
92
194
  * Merges a pack's `package.json` script additions into the existing
93
195
  * `scripts` block. Never overwrites a differing existing script -- the
94
196
  * collision is returned for the caller to report, consistent with adopt
95
- * mode's "report, then the user decides per conflict" policy.
197
+ * mode's "report, then the user decides per conflict" policy. An addition
198
+ * named `__proto__`, `constructor` or `prototype` is not a collision: it
199
+ * throws. The check runs per key inside the loop, before that key is read or
200
+ * written, and nothing escapes the failed merge -- `existing` is never mutated
201
+ * and no partial result is returned.
96
202
  */
97
203
  export function mergePackageScripts(existing, additions) {
98
204
  const scripts = { ...(existing ?? {}) };
99
205
  const collisions = [];
100
206
  for (const [name, cmd] of Object.entries(additions)) {
101
- const currentValue = scripts[name];
207
+ assertSafeKey(name, "package.json scripts");
208
+ // Object.hasOwn, not `scripts[name] !== undefined`: bracket access walks
209
+ // the prototype chain, so an addition named `toString`/`valueOf`
210
+ // would otherwise "collide" with an inherited Object.prototype member.
211
+ const currentValue = Object.hasOwn(scripts, name)
212
+ ? scripts[name]
213
+ : undefined;
102
214
  if (currentValue !== undefined) {
103
215
  if (currentValue !== cmd) {
104
216
  collisions.push({ name, existing: currentValue, incoming: cmd });
@@ -125,7 +237,7 @@ export function mergeVerifySteps(existing, additions) {
125
237
  continue;
126
238
  }
127
239
  const existingStep = current[existingIndex];
128
- if (JSON.stringify(existingStep) !== JSON.stringify(step)) {
240
+ if (!deepEqual(existingStep, step)) {
129
241
  throw new Error(`verify-steps.packs.json merge collision: step "${step.id}" is already registered with different config`);
130
242
  }
131
243
  // Identical entry already present -- idempotent no-op.
package/dist/mode.js CHANGED
@@ -1,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Decides whether the CLI is writing a fresh project into an empty directory
3
5
  * or adopting an already-established one. Adopt mode never overwrites
@@ -21,8 +23,16 @@ export function detectMode(dir) {
21
23
  if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
22
24
  return { mode: "adopt", signal: "found package.json" };
23
25
  }
24
- if (entries.some((entry) => entry.isDirectory() && entry.name === ".git")) {
25
- return { mode: "adopt", signal: "found a .git directory" };
26
+ // A worktree or submodule checkout has `.git` as a file (`gitdir: <path>`),
27
+ // not a directory -- both mean an established repository.
28
+ const gitEntry = entries.find((entry) => entry.name === ".git");
29
+ if (gitEntry !== undefined) {
30
+ return {
31
+ mode: "adopt",
32
+ signal: gitEntry.isDirectory()
33
+ ? "found a .git directory"
34
+ : "found a .git file (a worktree or submodule)",
35
+ };
26
36
  }
27
37
  const looseSource = entries.find((entry) => entry.isFile() && LOOSE_SOURCE_EXTENSIONS.has(extname(entry.name)));
28
38
  if (looseSource !== undefined) {
@@ -0,0 +1,210 @@
1
+ import type { Pack } from "./packs.js";
2
+ import type { TokenTable } from "./tokens.js";
3
+ /**
4
+ * The project-relative directory every pack is staged under; a pack's own
5
+ * staging directory is `${STAGED_PACKS_DIR}/<name>`.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const dir = `${STAGED_PACKS_DIR}/quality`; // ".groundwork/packs/quality"
10
+ * ```
11
+ */
12
+ export declare const STAGED_PACKS_DIR: string;
13
+ /**
14
+ * The install name of a pack's manifest; it is staged as
15
+ * `pack.json` + `.staged` beside the pack's `files/` directory.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const staged = `${STAGED_PACK_MANIFEST}.staged`; // "pack.json.staged"
20
+ * ```
21
+ */
22
+ export declare const STAGED_PACK_MANIFEST = "pack.json";
23
+ /**
24
+ * One staged pack file: its install path, its staged name, and the sha256 of
25
+ * the staged bytes, so `/customize` can verify the copy it installs.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * const file: StagedPackFile = {
30
+ * path: ".claude/hooks/guard-readonly-bash.mjs",
31
+ * staged: ".claude/hooks/guard-readonly-bash.mjs.staged",
32
+ * sha256: "e3b0c442...", // hex digest of the staged bytes
33
+ * };
34
+ * ```
35
+ */
36
+ export interface StagedPackFile {
37
+ /** The project-relative path the file installs to (for the manifest: `pack.json`). */
38
+ path: string;
39
+ /** The staged file's name: `path` + `.staged`, relative to the pack's `files/` directory (for the manifest: to the pack's own directory). */
40
+ staged: string;
41
+ /** Lowercase hex sha256 of the staged bytes. */
42
+ sha256: string;
43
+ }
44
+ /**
45
+ * One staged pack, as recorded in `inventory.json`'s `stagedPacks`.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * const pack: StagedPack = {
50
+ * name: "quality",
51
+ * dir: ".groundwork/packs/quality",
52
+ * suffix: ".staged",
53
+ * manifest: { path: "pack.json", staged: "pack.json.staged", sha256: "…" },
54
+ * files: [{ path: "bin/check-file-budget.mjs", staged: "bin/check-file-budget.mjs.staged", sha256: "…" }],
55
+ * };
56
+ * ```
57
+ */
58
+ export interface StagedPack {
59
+ /** The pack's manifest name, also its directory name under {@link STAGED_PACKS_DIR}. */
60
+ name: string;
61
+ /** The pack's project-relative staging directory, `/`-separated: `.groundwork/packs/<name>`. */
62
+ dir: string;
63
+ /** The suffix every staged name carries: `.staged`. */
64
+ suffix: string;
65
+ /** The staged `pack.json`, at `<dir>/pack.json.staged`. */
66
+ manifest: StagedPackFile;
67
+ /** The pack's `files/` tree, each at `<dir>/files/<staged>`, sorted by `path`. */
68
+ files: StagedPackFile[];
69
+ }
70
+ /**
71
+ * The validated plan for one {@link stagePacks} run: every path it would
72
+ * write, plus everything it needs to write them -- each pack's serialized
73
+ * manifest and its files' install paths and sources -- so staging never
74
+ * re-walks a pack's `files/` tree. Built by {@link planPackStaging}.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * import { planPackStaging, stagePacks } from "./pack-stage.js";
79
+ * const plan = planPackStaging(packs, groundworkDir, tokens);
80
+ * stagePacks(packs, groundworkDir, tokens, plan);
81
+ * ```
82
+ */
83
+ export interface PackStagingPlan {
84
+ /** The `groundworkDir` the plan was computed for; {@link stagePacks} refuses the plan for any other. */
85
+ readonly groundworkDir: string;
86
+ /** Every path the run writes, under `<groundworkDir>/packs/`: per pack, its `pack.json.staged`, then each `files/<path>.staged`. */
87
+ readonly paths: readonly string[];
88
+ /** Each pack's validated projection, in `packs` order: its name, serialized manifest, and each file's install path and source, sorted by path. */
89
+ readonly packs: readonly {
90
+ readonly name: string;
91
+ readonly manifestBytes: Buffer;
92
+ readonly files: readonly {
93
+ readonly path: string;
94
+ readonly sourcePath: string;
95
+ }[];
96
+ }[];
97
+ }
98
+ /**
99
+ * Validates `packs` and computes the plan {@link stagePacks} writes from:
100
+ * every path under `<groundworkDir>/packs/` -- each pack's
101
+ * `pack.json.staged` and every `files/<path>.staged` -- and each file's
102
+ * source, so adopt mode can scope-check `paths` before any pack is written
103
+ * and then hand the same plan to {@link stagePacks}. Reads the packs'
104
+ * source trees; writes nothing.
105
+ *
106
+ * @throws The same plan `Error`s as {@link stagePacks}.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { assertAdoptWriteScope } from "./main.js";
111
+ * const plan = planPackStaging(packs, groundworkDir, tokens);
112
+ * assertAdoptWriteScope(targetDir, plan.paths);
113
+ * stagePacks(packs, groundworkDir, tokens, plan);
114
+ * ```
115
+ */
116
+ export declare function planPackStaging(packs: readonly Pack[], groundworkDir: string, tokens: TokenTable): PackStagingPlan;
117
+ /**
118
+ * Every path {@link stagePacks} would write for `packs` -- a thin wrapper
119
+ * returning {@link planPackStaging}'s `paths`. Reads the packs' source
120
+ * trees; writes nothing.
121
+ *
122
+ * @throws The same plan `Error`s as {@link stagePacks}.
123
+ *
124
+ * @example
125
+ * ```ts
126
+ * import { assertAdoptWriteScope } from "./main.js";
127
+ * assertAdoptWriteScope(targetDir, plannedPackStagingPaths(packs, groundworkDir, tokens));
128
+ * ```
129
+ */
130
+ export declare function plannedPackStagingPaths(packs: readonly Pack[], groundworkDir: string, tokens: TokenTable): string[];
131
+ /**
132
+ * Stages every pack in `packs` into `<groundworkDir>/packs/`: for each, its
133
+ * manifest as `<name>/pack.json.staged` (`JSON.stringify(manifest, null, 2)`
134
+ * plus a newline) and every file of its `filesDir` tree as
135
+ * `<name>/files/<path>.staged`, copied byte-for-byte -- no token
136
+ * substitution into content, which is `/customize`'s job at install time.
137
+ * `tokens` only substitutes into install paths (and dotfile names are
138
+ * restored), the same derivation `planConflicts` uses. Recorded `path`/
139
+ * `staged` values use forward slashes; `dir` is the literal
140
+ * `.groundwork/packs/<name>`, whatever `groundworkDir` was passed.
141
+ *
142
+ * What is guaranteed:
143
+ * - The plan is validated first, before anything is deleted or written (or,
144
+ * when `plan` is passed, was already validated by {@link planPackStaging}
145
+ * and the packs' trees are not walked again): a pack name that is not a
146
+ * single directory name, contains `:`, or is used by two packs (ignoring
147
+ * case and Unicode normalization), a missing `filesDir`, two files in one
148
+ * pack installing to the same path, an install path containing `:`, a
149
+ * staged name that would escape its pack's staging directory, or two
150
+ * staged names in one pack landing on the same file (equal once
151
+ * NFC-normalized and case-folded, or one a directory prefix of the other)
152
+ * throws its own
153
+ * `Error`, leaving `.groundwork/` exactly as it was. A `filesDir` that
154
+ * cannot be inspected (a permission error) throws a plan `Error` naming
155
+ * the pack and path, with the failure as `cause`.
156
+ * - Then, still before anything is deleted or written, `groundworkDir` and
157
+ * `<groundworkDir>/packs` are checked not to be symlinks, and every
158
+ * `.packs-*` entry of the CLI-owned `groundworkDir` -- meant for work
159
+ * directories a crashed earlier run left, but removed whatever created
160
+ * it, so a concurrent run against the same directory can lose its
161
+ * in-progress work directory and fail with the incomplete/re-run error
162
+ * -- is removed (best effort; a failure to remove one only warns, a
163
+ * failure to list `groundworkDir` throws). `.baseline-*`
164
+ * entries are left alone.
165
+ * - When `packs` is empty, any previous `packs/` is removed and nothing is
166
+ * created.
167
+ * - Otherwise every pack is written into one temporary `.packs-*` sibling
168
+ * directory (each file created exclusively, `wx`) and swapped in by rename
169
+ * only after every copy succeeded, so `packs/` is never half-written and a
170
+ * failure leaves any previous `packs/` intact; a previous `packs/` is
171
+ * replaced wholesale (a pack no longer passed, or a file a pack dropped,
172
+ * does not linger).
173
+ * - With a previous `packs/`, the swap is two renames: the previous one is
174
+ * parked inside the temporary directory, then the new one moved into
175
+ * place. A process killed between the two leaves `packs/` absent and the
176
+ * previous copy at `.packs-XXXXXX/previous`; the next run's sweep removes
177
+ * it and regenerates the staging.
178
+ * - If the final swap rename fails, the previous `packs/` is renamed back.
179
+ * Only if that restore also fails is `packs/` left absent: the previous
180
+ * staging then survives, parked inside the temporary directory, which is
181
+ * deliberately not removed (a later run's stale-dir sweep does).
182
+ * - The temporary directory is otherwise always removed; a failure to remove
183
+ * it only warns, naming its path.
184
+ *
185
+ * @throws `Error` (no re-run advice; no `cause` except for an uninspectable
186
+ * `filesDir`) for an invalid plan, as above; `Error` before any delete or
187
+ * write when `groundworkDir` or
188
+ * `<groundworkDir>/packs` is a symlink; `AggregateError` of the swap and
189
+ * restore failures, naming where the previous `packs/` is parked, when both
190
+ * renames fail; the `AssertionError` itself, unwrapped, if the staged-path
191
+ * containment invariant ever fails while writing; otherwise an `Error` with
192
+ * `cause`, including the cause's message, saying `.groundwork/` is
193
+ * incomplete and the CLI should be re-run.
194
+ *
195
+ * @param plan - The plan {@link planPackStaging} computed for these same
196
+ * `packs`, `groundworkDir` and `tokens`; computed here when omitted. A plan
197
+ * whose `groundworkDir` resolves to a different directory throws a plain
198
+ * `Error` naming both ("the plan was built for …") before anything is
199
+ * deleted or written.
200
+ *
201
+ * @example
202
+ * ```ts
203
+ * import { listPackNames, loadPack } from "./packs.js";
204
+ * const packs = listPackNames().map((name) => loadPack(name));
205
+ * const staged = stagePacks(packs, "/work/app/.groundwork", { PROJECT_NAME: "app" });
206
+ * // staged[0]: { name: "github", dir: ".groundwork/packs/github", suffix: ".staged", … }
207
+ * ```
208
+ */
209
+ export declare function stagePacks(packs: readonly Pack[], groundworkDir: string, tokens: TokenTable, plan?: PackStagingPlan): StagedPack[];
210
+ //# sourceMappingURL=pack-stage.d.ts.map