@monte3l/groundwork 1.0.0-rc.2 → 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 +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 +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,13 @@
1
+ /**
2
+ * Reads `dir/package.json` as an object. Absent is `undefined` with nothing
3
+ * recorded; unreadable (`EACCES`/`EPERM`) or unparseable is `undefined` with
4
+ * an `undetermined` entry; any other read failure throws.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * const undetermined: string[] = [];
9
+ * const manifest = readPackageJson("/path/to/project", undetermined);
10
+ * ```
11
+ */
12
+ export declare function readPackageJson(dir: string, undetermined: string[]): Record<string, unknown> | undefined;
13
+ //# sourceMappingURL=package-json.d.ts.map
@@ -0,0 +1,37 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The `package.json` read `survey-shape.ts` and `survey-toolchain.ts` share,
5
+ * so the two collectors cannot disagree about what an unreadable or
6
+ * malformed manifest means.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { guardedExists, guardedRead } from "./read-guard.js";
11
+ /**
12
+ * Reads `dir/package.json` as an object. Absent is `undefined` with nothing
13
+ * recorded; unreadable (`EACCES`/`EPERM`) or unparseable is `undefined` with
14
+ * an `undetermined` entry; any other read failure throws.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const undetermined: string[] = [];
19
+ * const manifest = readPackageJson("/path/to/project", undetermined);
20
+ * ```
21
+ */
22
+ export function readPackageJson(dir, undetermined) {
23
+ const path = join(dir, "package.json");
24
+ if (!guardedExists(path, undetermined))
25
+ return undefined;
26
+ const content = guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
27
+ if (content === undefined)
28
+ return undefined;
29
+ try {
30
+ return JSON.parse(content);
31
+ }
32
+ catch (error) {
33
+ undetermined.push(`could not parse ${path}: ${error instanceof Error ? error.message : String(error)}`);
34
+ return undefined;
35
+ }
36
+ }
37
+ //# sourceMappingURL=package-json.js.map
@@ -0,0 +1,178 @@
1
+ /**
2
+ * A survey read that failed for a reason other than permission. Carries the
3
+ * original failure as `cause`.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * throw new SurveyReadError("could not read /p/README.md", { cause });
8
+ * ```
9
+ */
10
+ export declare class SurveyReadError extends Error {
11
+ name: string;
12
+ }
13
+ /**
14
+ * The `code` of a Node system error, or `undefined` for anything without a
15
+ * string `code`.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * errnoCode(Object.assign(new Error("x"), { code: "ENOENT" })); // "ENOENT"
20
+ * ```
21
+ */
22
+ export declare function errnoCode(error: unknown): string | undefined;
23
+ /**
24
+ * The permission errno carried by `error` (`EACCES`/`EPERM`), or `undefined`
25
+ * when `error` is anything else.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * permissionCode(Object.assign(new Error("x"), { code: "EACCES" })); // "EACCES"
30
+ * permissionCode(Object.assign(new Error("x"), { code: "EIO" })); // undefined
31
+ * ```
32
+ */
33
+ export declare function permissionCode(error: unknown): string | undefined;
34
+ /**
35
+ * The `undetermined` entry recorded for an unreadable path. Identical text
36
+ * for a file and a directory, so two collectors that hit the same
37
+ * unreadable path produce one entry once the aggregate de-duplicates.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * unreadableNote("/p/CLAUDE.md", "EACCES");
42
+ * // "/p/CLAUDE.md is unreadable (EACCES) -- its contents are not in this survey"
43
+ * ```
44
+ */
45
+ export declare function unreadableNote(path: string, code: string): string;
46
+ /**
47
+ * Wraps a non-permission read failure on `path` in a {@link SurveyReadError}
48
+ * naming the path, with `cause` as the original failure (which carries the
49
+ * errno code itself).
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * try {
54
+ * readFileSync(path, "utf8");
55
+ * } catch (cause) {
56
+ * throw readFailure(path, cause);
57
+ * }
58
+ * ```
59
+ */
60
+ export declare function readFailure(path: string, cause: unknown): SurveyReadError;
61
+ /**
62
+ * Whether `error` carries an errno that means "nothing is at this path"
63
+ * (`ENOENT`/`ENOTDIR`).
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * isAbsentError(Object.assign(new Error("x"), { code: "ENOENT" })); // true
68
+ * isAbsentError(Object.assign(new Error("x"), { code: "ELOOP" })); // false
69
+ * ```
70
+ */
71
+ export declare function isAbsentError(error: unknown): boolean;
72
+ /**
73
+ * The errno to record when `error` says the path holds something this
74
+ * process cannot read or resolve -- a permission code (`EACCES`/`EPERM`) or
75
+ * a symlink loop (`ELOOP`) -- or `undefined` for anything else.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * unresolvableCode(Object.assign(new Error("x"), { code: "ELOOP" })); // "ELOOP"
80
+ * unresolvableCode(Object.assign(new Error("x"), { code: "EIO" })); // undefined
81
+ * ```
82
+ */
83
+ export declare function unresolvableCode(error: unknown): string | undefined;
84
+ /**
85
+ * What a `stat` on a path established: something is there, nothing is there,
86
+ * or something is there (or may be) that this process cannot reach --
87
+ * carrying the errno that says why.
88
+ */
89
+ export type PathProbe = {
90
+ readonly kind: "present";
91
+ } | {
92
+ readonly kind: "absent";
93
+ } | {
94
+ readonly kind: "unresolvable";
95
+ readonly code: string;
96
+ };
97
+ /**
98
+ * Probes `path` with a real `stat`, never `existsSync` (which answers
99
+ * `false` for ANY failure, so a file under a `chmod 000` directory reads as
100
+ * absent). `ENOENT`/`ENOTDIR` is `absent`; `EACCES`/`EPERM`/`ELOOP` is
101
+ * `unresolvable` with the errno -- never folded into `absent`. Any other
102
+ * failure throws a {@link SurveyReadError} naming `path`, with the original
103
+ * as `cause`.
104
+ *
105
+ * @example
106
+ * ```ts
107
+ * const probe = probePath(targetPath);
108
+ * if (probe.kind === "unresolvable") undetermined.push(unreadableNote(targetPath, probe.code));
109
+ * ```
110
+ */
111
+ export declare function probePath(path: string): PathProbe;
112
+ /**
113
+ * The path an `undetermined` note names when a `stat` of `path` failed with
114
+ * `code` (from {@link probePath}'s `unresolvable` result). A `stat` needs
115
+ * search permission on the path's ancestors, never on the path itself, so a
116
+ * permission failure names the enclosing directory -- one entry for every
117
+ * probe under it once the caller de-duplicates. A symlink loop (`ELOOP`) is
118
+ * a property of the path itself, so it names `path`.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const probe = probePath(targetPath);
123
+ * if (probe.kind === "unresolvable") {
124
+ * undetermined.push(unreadableNote(probeSubject(targetPath, probe.code), probe.code));
125
+ * }
126
+ * ```
127
+ */
128
+ export declare function probeSubject(path: string, code: string): string;
129
+ /**
130
+ * Whether `path` exists, without `existsSync`'s blind spot: `existsSync`
131
+ * answers `false` for ANY `stat` failure, so a path under a directory this
132
+ * process may not enter (`chmod 000`) reads as silently absent. A `stat`
133
+ * needs search permission on the path's ancestors, never on the path
134
+ * itself, so a permission failure here is recorded in `undetermined` naming
135
+ * the enclosing directory (one entry for every probe under it once the
136
+ * aggregate de-duplicates) and answers `false`; the survey carries on. A
137
+ * symlink loop (`ELOOP`) is a property of the path itself, so it is
138
+ * recorded naming `path` and answers `false`. A genuinely absent path
139
+ * (`ENOENT`/`ENOTDIR`) answers `false` with nothing recorded. Any other
140
+ * failure throws a {@link SurveyReadError} naming `path`, with the original
141
+ * as `cause`.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * if (!guardedExists(settingsPath, undetermined)) return undefined;
146
+ * ```
147
+ */
148
+ export declare function guardedExists(path: string, undetermined: string[]): boolean;
149
+ /**
150
+ * The errno to record when a read of an already-discovered path failed for a
151
+ * reason that is a property of the project's own tree -- `EACCES`/`EPERM`,
152
+ * `ENOENT` (a dangling symlink), `ELOOP`, `EISDIR` or `ENOTDIR` -- or `undefined` for
153
+ * anything else (`EIO`, `EMFILE`, ...), which the caller throws.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * recordedReadCode(Object.assign(new Error("x"), { code: "EISDIR" })); // "EISDIR"
158
+ * recordedReadCode(Object.assign(new Error("x"), { code: "EIO" })); // undefined
159
+ * ```
160
+ */
161
+ export declare function recordedReadCode(error: unknown): string | undefined;
162
+ /**
163
+ * Runs `read` against `path`. On a failure that is a property of the
164
+ * project's own tree -- a permission failure (`EACCES`/`EPERM`), a dangling
165
+ * symlink (`ENOENT`), a symlink loop (`ELOOP`), a directory where a file
166
+ * was expected (`EISDIR`), or a file where a directory was expected
167
+ * (`ENOTDIR`) -- records the path and errno in `undetermined`
168
+ * and returns `undefined`. On any other failure (`EIO`, `EMFILE`, ...),
169
+ * throws a {@link SurveyReadError} chaining the original as `cause`.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * const content = guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
174
+ * const headings = content === undefined ? [] : extractHeadings(content);
175
+ * ```
176
+ */
177
+ export declare function guardedRead<T>(path: string, read: () => T, undetermined: string[]): T | undefined;
178
+ //# sourceMappingURL=read-guard.d.ts.map
@@ -0,0 +1,276 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * The one errno discrimination every adopt-mode survey read goes through.
5
+ * A permission failure (`EACCES`/`EPERM`) is a property of the project file
6
+ * itself -- a `chmod 000` left behind, a root-owned file -- so it is recorded
7
+ * in the survey's `undetermined` list and the survey carries on; so is a
8
+ * dangling symlink (`ENOENT`), a symlink loop (`ELOOP`), a directory where
9
+ * a file was expected (`EISDIR`) or a file where a directory was expected
10
+ * (`ENOTDIR`) met on a read. Anything
11
+ * else (`EIO`, `EMFILE`, ...) says something about the machine, not the
12
+ * project, and is thrown with the path named and the original chained as
13
+ * `cause`, never folded silently in beside a genuine permission problem.
14
+ */
15
+ import { statSync } from "node:fs";
16
+ import { dirname } from "node:path";
17
+ /** The errno codes that mean "this entry exists but this process may not read it". */
18
+ const PERMISSION_CODES = new Set(["EACCES", "EPERM"]);
19
+ /**
20
+ * A survey read that failed for a reason other than permission. Carries the
21
+ * original failure as `cause`.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * throw new SurveyReadError("could not read /p/README.md", { cause });
26
+ * ```
27
+ */
28
+ export class SurveyReadError extends Error {
29
+ name = "SurveyReadError";
30
+ }
31
+ /**
32
+ * The `code` of a Node system error, or `undefined` for anything without a
33
+ * string `code`.
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * errnoCode(Object.assign(new Error("x"), { code: "ENOENT" })); // "ENOENT"
38
+ * ```
39
+ */
40
+ export function errnoCode(error) {
41
+ if (typeof error !== "object" || error === null)
42
+ return undefined;
43
+ // One read into a local, so the value checked is the value returned.
44
+ const code = Reflect.get(error, "code");
45
+ return typeof code === "string" ? code : undefined;
46
+ }
47
+ /**
48
+ * The permission errno carried by `error` (`EACCES`/`EPERM`), or `undefined`
49
+ * when `error` is anything else.
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * permissionCode(Object.assign(new Error("x"), { code: "EACCES" })); // "EACCES"
54
+ * permissionCode(Object.assign(new Error("x"), { code: "EIO" })); // undefined
55
+ * ```
56
+ */
57
+ export function permissionCode(error) {
58
+ const code = errnoCode(error);
59
+ return code !== undefined && PERMISSION_CODES.has(code) ? code : undefined;
60
+ }
61
+ /**
62
+ * The `undetermined` entry recorded for an unreadable path. Identical text
63
+ * for a file and a directory, so two collectors that hit the same
64
+ * unreadable path produce one entry once the aggregate de-duplicates.
65
+ *
66
+ * @example
67
+ * ```ts
68
+ * unreadableNote("/p/CLAUDE.md", "EACCES");
69
+ * // "/p/CLAUDE.md is unreadable (EACCES) -- its contents are not in this survey"
70
+ * ```
71
+ */
72
+ export function unreadableNote(path, code) {
73
+ return `${path} is unreadable (${code}) -- its contents are not in this survey`;
74
+ }
75
+ /**
76
+ * Wraps a non-permission read failure on `path` in a {@link SurveyReadError}
77
+ * naming the path, with `cause` as the original failure (which carries the
78
+ * errno code itself).
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * try {
83
+ * readFileSync(path, "utf8");
84
+ * } catch (cause) {
85
+ * throw readFailure(path, cause);
86
+ * }
87
+ * ```
88
+ */
89
+ export function readFailure(path, cause) {
90
+ return new SurveyReadError(`could not read ${path}`, { cause });
91
+ }
92
+ /**
93
+ * The errno codes that mean "nothing is at this path": nothing there
94
+ * (`ENOENT`) or an ancestor is a file (`ENOTDIR`). The one definition every
95
+ * survey probe (`guardedExists`, `fs-walk.ts`'s `walkBounded`) and
96
+ * `jsonc.ts`'s `readJsoncFile` share, so they cannot disagree on what
97
+ * "absent" means. `ELOOP` is deliberately NOT here: a symlink loop is
98
+ * something at the path this process cannot resolve, not an absence -- see
99
+ * {@link UNRESOLVABLE_CODE}.
100
+ */
101
+ const ABSENT_CODES = new Set(["ENOENT", "ENOTDIR"]);
102
+ /**
103
+ * The errno a symlink loop raises: an entry exists at the path, but it never
104
+ * resolves. Recorded like a permission failure, never folded into "absent".
105
+ */
106
+ const UNRESOLVABLE_CODE = "ELOOP";
107
+ /**
108
+ * Whether `error` carries an errno that means "nothing is at this path"
109
+ * (`ENOENT`/`ENOTDIR`).
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * isAbsentError(Object.assign(new Error("x"), { code: "ENOENT" })); // true
114
+ * isAbsentError(Object.assign(new Error("x"), { code: "ELOOP" })); // false
115
+ * ```
116
+ */
117
+ export function isAbsentError(error) {
118
+ const code = errnoCode(error);
119
+ return code !== undefined && ABSENT_CODES.has(code);
120
+ }
121
+ /**
122
+ * The errno to record when `error` says the path holds something this
123
+ * process cannot read or resolve -- a permission code (`EACCES`/`EPERM`) or
124
+ * a symlink loop (`ELOOP`) -- or `undefined` for anything else.
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * unresolvableCode(Object.assign(new Error("x"), { code: "ELOOP" })); // "ELOOP"
129
+ * unresolvableCode(Object.assign(new Error("x"), { code: "EIO" })); // undefined
130
+ * ```
131
+ */
132
+ export function unresolvableCode(error) {
133
+ const permission = permissionCode(error);
134
+ if (permission !== undefined)
135
+ return permission;
136
+ return errnoCode(error) === UNRESOLVABLE_CODE ? UNRESOLVABLE_CODE : undefined;
137
+ }
138
+ /**
139
+ * Probes `path` with a real `stat`, never `existsSync` (which answers
140
+ * `false` for ANY failure, so a file under a `chmod 000` directory reads as
141
+ * absent). `ENOENT`/`ENOTDIR` is `absent`; `EACCES`/`EPERM`/`ELOOP` is
142
+ * `unresolvable` with the errno -- never folded into `absent`. Any other
143
+ * failure throws a {@link SurveyReadError} naming `path`, with the original
144
+ * as `cause`.
145
+ *
146
+ * @example
147
+ * ```ts
148
+ * const probe = probePath(targetPath);
149
+ * if (probe.kind === "unresolvable") undetermined.push(unreadableNote(targetPath, probe.code));
150
+ * ```
151
+ */
152
+ export function probePath(path) {
153
+ try {
154
+ statSync(path);
155
+ return { kind: "present" };
156
+ }
157
+ catch (error) {
158
+ if (isAbsentError(error))
159
+ return { kind: "absent" };
160
+ const code = unresolvableCode(error);
161
+ if (code !== undefined)
162
+ return { kind: "unresolvable", code };
163
+ throw new SurveyReadError(`could not check whether ${path} exists`, {
164
+ cause: error,
165
+ });
166
+ }
167
+ }
168
+ /**
169
+ * The path an `undetermined` note names when a `stat` of `path` failed with
170
+ * `code` (from {@link probePath}'s `unresolvable` result). A `stat` needs
171
+ * search permission on the path's ancestors, never on the path itself, so a
172
+ * permission failure names the enclosing directory -- one entry for every
173
+ * probe under it once the caller de-duplicates. A symlink loop (`ELOOP`) is
174
+ * a property of the path itself, so it names `path`.
175
+ *
176
+ * @example
177
+ * ```ts
178
+ * const probe = probePath(targetPath);
179
+ * if (probe.kind === "unresolvable") {
180
+ * undetermined.push(unreadableNote(probeSubject(targetPath, probe.code), probe.code));
181
+ * }
182
+ * ```
183
+ */
184
+ export function probeSubject(path, code) {
185
+ return code === UNRESOLVABLE_CODE ? path : dirname(path);
186
+ }
187
+ /**
188
+ * Whether `path` exists, without `existsSync`'s blind spot: `existsSync`
189
+ * answers `false` for ANY `stat` failure, so a path under a directory this
190
+ * process may not enter (`chmod 000`) reads as silently absent. A `stat`
191
+ * needs search permission on the path's ancestors, never on the path
192
+ * itself, so a permission failure here is recorded in `undetermined` naming
193
+ * the enclosing directory (one entry for every probe under it once the
194
+ * aggregate de-duplicates) and answers `false`; the survey carries on. A
195
+ * symlink loop (`ELOOP`) is a property of the path itself, so it is
196
+ * recorded naming `path` and answers `false`. A genuinely absent path
197
+ * (`ENOENT`/`ENOTDIR`) answers `false` with nothing recorded. Any other
198
+ * failure throws a {@link SurveyReadError} naming `path`, with the original
199
+ * as `cause`.
200
+ *
201
+ * @example
202
+ * ```ts
203
+ * if (!guardedExists(settingsPath, undetermined)) return undefined;
204
+ * ```
205
+ */
206
+ export function guardedExists(path, undetermined) {
207
+ const probe = probePath(path);
208
+ if (probe.kind === "present")
209
+ return true;
210
+ if (probe.kind === "absent")
211
+ return false;
212
+ undetermined.push(unreadableNote(probeSubject(path, probe.code), probe.code));
213
+ return false;
214
+ }
215
+ /**
216
+ * The non-permission errnos a read of an already-discovered path records
217
+ * rather than throws: the entry is a dangling symlink (`ENOENT`), a symlink
218
+ * loop (`ELOOP`), a directory where a file was expected (`EISDIR`), or a
219
+ * file where a directory was expected (`ENOTDIR`, e.g. a `readdirSync` of a
220
+ * path a prior `stat` found present). Each
221
+ * is a property of the project's own tree, like a permission failure -- not
222
+ * of the machine, like `EIO`/`EMFILE`.
223
+ */
224
+ const RECORDED_READ_CODES = new Set([
225
+ "ENOENT",
226
+ UNRESOLVABLE_CODE,
227
+ "EISDIR",
228
+ "ENOTDIR",
229
+ ]);
230
+ /**
231
+ * The errno to record when a read of an already-discovered path failed for a
232
+ * reason that is a property of the project's own tree -- `EACCES`/`EPERM`,
233
+ * `ENOENT` (a dangling symlink), `ELOOP`, `EISDIR` or `ENOTDIR` -- or `undefined` for
234
+ * anything else (`EIO`, `EMFILE`, ...), which the caller throws.
235
+ *
236
+ * @example
237
+ * ```ts
238
+ * recordedReadCode(Object.assign(new Error("x"), { code: "EISDIR" })); // "EISDIR"
239
+ * recordedReadCode(Object.assign(new Error("x"), { code: "EIO" })); // undefined
240
+ * ```
241
+ */
242
+ export function recordedReadCode(error) {
243
+ const permission = permissionCode(error);
244
+ if (permission !== undefined)
245
+ return permission;
246
+ const code = errnoCode(error);
247
+ return code !== undefined && RECORDED_READ_CODES.has(code) ? code : undefined;
248
+ }
249
+ /**
250
+ * Runs `read` against `path`. On a failure that is a property of the
251
+ * project's own tree -- a permission failure (`EACCES`/`EPERM`), a dangling
252
+ * symlink (`ENOENT`), a symlink loop (`ELOOP`), a directory where a file
253
+ * was expected (`EISDIR`), or a file where a directory was expected
254
+ * (`ENOTDIR`) -- records the path and errno in `undetermined`
255
+ * and returns `undefined`. On any other failure (`EIO`, `EMFILE`, ...),
256
+ * throws a {@link SurveyReadError} chaining the original as `cause`.
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * const content = guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
261
+ * const headings = content === undefined ? [] : extractHeadings(content);
262
+ * ```
263
+ */
264
+ export function guardedRead(path, read, undetermined) {
265
+ try {
266
+ return read();
267
+ }
268
+ catch (error) {
269
+ const code = recordedReadCode(error);
270
+ if (code === undefined)
271
+ throw readFailure(path, error);
272
+ undetermined.push(unreadableNote(path, code));
273
+ return undefined;
274
+ }
275
+ }
276
+ //# sourceMappingURL=read-guard.js.map
@@ -1,4 +1,19 @@
1
1
  import type { DocsSurvey } from "./types.js";
2
- /** Indexes docs/guideline files at `dir`. Offline, read-only, index-only -- no full content. */
3
- export declare function surveyDocs(dir: string): DocsSurvey;
2
+ /**
3
+ * Indexes docs/guideline files at `dir`. Offline, read-only, index-only --
4
+ * no full content. A doc or doc directory that exists but cannot be read
5
+ * (`EACCES`/`EPERM`) is recorded in `undetermined` (an unreadable doc keeps
6
+ * its entry with empty `headings`); any other read failure throws, naming
7
+ * the path, with the original failure as `cause`.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { surveyDocs } from "./survey-docs.js";
12
+ *
13
+ * const undetermined: string[] = [];
14
+ * const docs = surveyDocs("/path/to/project", undetermined);
15
+ * console.log(docs.files.map((file) => file.path), undetermined);
16
+ * ```
17
+ */
18
+ export declare function surveyDocs(dir: string, undetermined: string[]): DocsSurvey;
4
19
  //# sourceMappingURL=survey-docs.d.ts.map
@@ -1,14 +1,18 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Indexes human-facing docs and guidelines: `CONTRIBUTING.md`, a
3
5
  * `docs/contributing/` tree, ADR/decision/RFC directories, style guides, and
4
6
  * the README's own heading outline. This is an index (path, size, headings)
5
- * so `/customize`'s Step 0 knows what exists and where to read it in full --
7
+ * so `/customize`'s Step 0 (the adopt-mode reconcile step in `/customize`)
8
+ * knows what exists and where to read it in full --
6
9
  * it never inlines a doc's content itself, which would make the survey's
7
10
  * own output as large as the docs it's indexing.
8
11
  */
9
- import { existsSync, readFileSync, statSync } from "node:fs";
12
+ import { readFileSync, statSync } from "node:fs";
10
13
  import { join } from "node:path";
11
14
  import { walkBounded } from "./fs-walk.js";
15
+ import { guardedExists, guardedRead } from "./internal/read-guard.js";
12
16
  const NAMED_ROOT_CANDIDATES = ["README.md", "CONTRIBUTING.md"];
13
17
  const NAMED_DIR_CANDIDATES = [
14
18
  "docs/contributing",
@@ -24,46 +28,82 @@ function extractHeadings(content) {
24
28
  .filter((line) => /^#{1,3}\s/.test(line))
25
29
  .map((line) => line.replace(/^#{1,3}\s*/, "").trim());
26
30
  }
27
- function indexFile(path) {
28
- const content = readFileSync(path, "utf8");
31
+ /**
32
+ * Indexes one doc. Its size comes from `statSync`, which needs no read
33
+ * permission on the file itself, so an unreadable doc keeps its entry with
34
+ * empty `headings` (and is recorded); a doc that cannot even be stat'ed is
35
+ * left out (and recorded).
36
+ */
37
+ function indexFile(path, undetermined) {
38
+ const sizeBytes = guardedRead(path, () => statSync(path).size, undetermined);
39
+ if (sizeBytes === undefined)
40
+ return undefined;
41
+ const content = guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
29
42
  return {
30
43
  path,
31
- sizeBytes: statSync(path).size,
32
- headings: extractHeadings(content),
44
+ sizeBytes,
45
+ headings: content === undefined ? [] : extractHeadings(content),
33
46
  };
34
47
  }
35
- function collectRootMarkdown(dir) {
36
- const found = [];
48
+ function collectRootMarkdown(dir, undetermined) {
49
+ const paths = [];
37
50
  for (const name of NAMED_ROOT_CANDIDATES) {
38
51
  const path = join(dir, name);
39
- if (existsSync(path))
40
- found.push(indexFile(path));
52
+ if (guardedExists(path, undetermined))
53
+ paths.push(path);
41
54
  }
42
- for (const entry of walkBounded(dir, 0)) {
55
+ for (const entry of walkBounded(dir, 0, undetermined)) {
43
56
  if (!entry.isDirectory && /^STYLE.*\.md$/i.test(entry.relPath)) {
44
- found.push(indexFile(entry.path));
57
+ paths.push(entry.path);
45
58
  }
46
59
  }
47
- return found;
60
+ return indexAll(paths, undetermined);
48
61
  }
49
- function collectNamedDirectories(dir) {
50
- const found = [];
62
+ function collectNamedDirectories(dir, undetermined) {
63
+ const paths = [];
51
64
  for (const relDir of NAMED_DIR_CANDIDATES) {
52
65
  const absDir = join(dir, relDir);
53
- if (!existsSync(absDir))
66
+ if (!guardedExists(absDir, undetermined))
54
67
  continue;
55
- for (const entry of walkBounded(absDir, 2)) {
68
+ for (const entry of walkBounded(absDir, 2, undetermined)) {
56
69
  if (!entry.isDirectory && entry.relPath.endsWith(".md")) {
57
- found.push(indexFile(entry.path));
70
+ paths.push(entry.path);
58
71
  }
59
72
  }
60
73
  }
74
+ return indexAll(paths, undetermined);
75
+ }
76
+ function indexAll(paths, undetermined) {
77
+ const found = [];
78
+ for (const path of paths) {
79
+ const file = indexFile(path, undetermined);
80
+ if (file !== undefined)
81
+ found.push(file);
82
+ }
61
83
  return found;
62
84
  }
63
- /** Indexes docs/guideline files at `dir`. Offline, read-only, index-only -- no full content. */
64
- export function surveyDocs(dir) {
85
+ /**
86
+ * Indexes docs/guideline files at `dir`. Offline, read-only, index-only --
87
+ * no full content. A doc or doc directory that exists but cannot be read
88
+ * (`EACCES`/`EPERM`) is recorded in `undetermined` (an unreadable doc keeps
89
+ * its entry with empty `headings`); any other read failure throws, naming
90
+ * the path, with the original failure as `cause`.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * import { surveyDocs } from "./survey-docs.js";
95
+ *
96
+ * const undetermined: string[] = [];
97
+ * const docs = surveyDocs("/path/to/project", undetermined);
98
+ * console.log(docs.files.map((file) => file.path), undetermined);
99
+ * ```
100
+ */
101
+ export function surveyDocs(dir, undetermined) {
65
102
  return {
66
- files: [...collectRootMarkdown(dir), ...collectNamedDirectories(dir)],
103
+ files: [
104
+ ...collectRootMarkdown(dir, undetermined),
105
+ ...collectNamedDirectories(dir, undetermined),
106
+ ],
67
107
  };
68
108
  }
69
109
  //# sourceMappingURL=survey-docs.js.map
@@ -1,4 +1,22 @@
1
1
  import type { HarnessSurvey } from "./types.js";
2
- /** Surveys the `.claude/` harness and `CLAUDE.md` at `dir`. Offline, read-only. */
3
- export declare function surveyHarness(dir: string): HarnessSurvey;
2
+ /**
3
+ * Surveys the `.claude/` harness and `CLAUDE.md` at `dir`. Offline,
4
+ * read-only. A file or directory that exists but cannot be read
5
+ * (`EACCES`/`EPERM`) is left out of its collection and recorded in
6
+ * `undetermined` -- an unreadable `CLAUDE.md` still counts as present, with
7
+ * no headings. A `.claude/` this process may not enter is still `present`,
8
+ * with every collection empty and the directory recorded in `undetermined`
9
+ * -- never reported as an empty harness. Any other read failure throws, naming the path, with the
10
+ * original failure as `cause`.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { surveyHarness } from "./survey-harness.js";
15
+ *
16
+ * const undetermined: string[] = [];
17
+ * const harness = surveyHarness("/path/to/project", undetermined);
18
+ * console.log(harness.agents.map((agent) => agent.name), undetermined);
19
+ * ```
20
+ */
21
+ export declare function surveyHarness(dir: string, undetermined: string[]): HarnessSurvey;
4
22
  //# sourceMappingURL=survey-harness.d.ts.map