@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.
- package/README.md +13 -5
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +574 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +21 -13
- package/dist/packs.js +241 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +44 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +295 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +41 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
package/dist/merge-json.d.ts
CHANGED
|
@@ -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 {
|
package/dist/merge-json.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
25
|
-
|
|
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
|