@monte3l/groundwork 0.1.0-next.1 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/README.md +16 -8
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -1,12 +1,15 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Collects the whole `.claude/` surface of an existing project -- hook
3
5
  * wiring, agent/skill/rule frontmatter, commands, and `CLAUDE.md`'s heading
4
6
  * outline -- so `/customize`'s harness sweep starts from what is actually
5
7
  * there instead of assuming the m3l-groundwork baseline.
6
8
  */
7
- import { existsSync, readFileSync, readdirSync } from "node:fs";
9
+ import { readFileSync, readdirSync } from "node:fs";
8
10
  import { join } from "node:path";
9
11
  import { fieldText, parseFrontmatter } from "../harness/frontmatter.js";
12
+ import { guardedExists, guardedRead } from "./internal/read-guard.js";
10
13
  /**
11
14
  * Reads one frontmatter field as text (a list is joined with `, `), or
12
15
  * `undefined` when the file has no frontmatter, the field is absent, or its
@@ -21,33 +24,48 @@ function extractFrontmatterField(content, field) {
21
24
  const text = fieldText(parsed.fields, field);
22
25
  return text === undefined || text === "" ? undefined : text;
23
26
  }
24
- function listMarkdownFiles(dir) {
25
- if (!existsSync(dir))
27
+ /** Reads `path` as UTF-8 text, or `undefined` (recorded) when it is unreadable. */
28
+ function readText(path, undetermined) {
29
+ return guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
30
+ }
31
+ /** Lists `dir`'s entry names: `[]` when it is absent, or unreadable (recorded). */
32
+ function listNames(dir, undetermined) {
33
+ if (!guardedExists(dir, undetermined))
26
34
  return [];
27
- return readdirSync(dir).filter((name) => name.endsWith(".md"));
35
+ return guardedRead(dir, () => readdirSync(dir), undetermined) ?? [];
36
+ }
37
+ function listMarkdownFiles(dir, undetermined) {
38
+ return listNames(dir, undetermined).filter((name) => name.endsWith(".md"));
28
39
  }
29
- function surveyAgents(claudeDir) {
40
+ function surveyAgents(claudeDir, undetermined) {
30
41
  const agentsDir = join(claudeDir, "agents");
31
- return listMarkdownFiles(agentsDir).map((name) => {
32
- const content = readFileSync(join(agentsDir, name), "utf8");
33
- return {
42
+ const results = [];
43
+ for (const name of listMarkdownFiles(agentsDir, undetermined)) {
44
+ const content = readText(join(agentsDir, name), undetermined);
45
+ if (content === undefined)
46
+ continue;
47
+ results.push({
34
48
  name: name.replace(/\.md$/, ""),
35
49
  model: extractFrontmatterField(content, "model"),
36
- };
37
- });
50
+ });
51
+ }
52
+ return results;
38
53
  }
39
- function surveySkills(claudeDir) {
54
+ function surveySkills(claudeDir, undetermined) {
40
55
  const skillsDir = join(claudeDir, "skills");
41
- if (!existsSync(skillsDir))
56
+ if (!guardedExists(skillsDir, undetermined))
42
57
  return [];
58
+ const entries = guardedRead(skillsDir, () => readdirSync(skillsDir, { withFileTypes: true }), undetermined) ?? [];
43
59
  const results = [];
44
- for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
60
+ for (const entry of entries) {
45
61
  if (!entry.isDirectory())
46
62
  continue;
47
63
  const skillFile = join(skillsDir, entry.name, "SKILL.md");
48
- if (!existsSync(skillFile))
64
+ if (!guardedExists(skillFile, undetermined))
65
+ continue;
66
+ const content = readText(skillFile, undetermined);
67
+ if (content === undefined)
49
68
  continue;
50
- const content = readFileSync(skillFile, "utf8");
51
69
  results.push({
52
70
  name: extractFrontmatterField(content, "name") ?? entry.name,
53
71
  description: extractFrontmatterField(content, "description"),
@@ -55,25 +73,25 @@ function surveySkills(claudeDir) {
55
73
  }
56
74
  return results;
57
75
  }
58
- function surveyHooks(claudeDir) {
59
- const hooksDir = join(claudeDir, "hooks");
60
- if (!existsSync(hooksDir))
61
- return [];
62
- return readdirSync(hooksDir).filter((name) => name.endsWith(".mjs") || name.endsWith(".js"));
76
+ function surveyHooks(claudeDir, undetermined) {
77
+ return listNames(join(claudeDir, "hooks"), undetermined).filter((name) => name.endsWith(".mjs") || name.endsWith(".js"));
63
78
  }
64
- function surveyRules(claudeDir) {
79
+ function surveyRules(claudeDir, undetermined) {
65
80
  const rulesDir = join(claudeDir, "rules");
66
- return listMarkdownFiles(rulesDir).map((name) => {
67
- const content = readFileSync(join(rulesDir, name), "utf8");
68
- return {
81
+ const results = [];
82
+ for (const name of listMarkdownFiles(rulesDir, undetermined)) {
83
+ const content = readText(join(rulesDir, name), undetermined);
84
+ if (content === undefined)
85
+ continue;
86
+ results.push({
69
87
  name: name.replace(/\.md$/, ""),
70
88
  paths: extractFrontmatterField(content, "paths"),
71
- };
72
- });
89
+ });
90
+ }
91
+ return results;
73
92
  }
74
- function surveyCommands(claudeDir) {
75
- const commandsDir = join(claudeDir, "commands");
76
- return listMarkdownFiles(commandsDir);
93
+ function surveyCommands(claudeDir, undetermined) {
94
+ return listMarkdownFiles(join(claudeDir, "commands"), undetermined);
77
95
  }
78
96
  function extractHeadings(content) {
79
97
  return content
@@ -81,12 +99,37 @@ function extractHeadings(content) {
81
99
  .filter((line) => /^#{1,3}\s/.test(line))
82
100
  .map((line) => line.replace(/^#{1,3}\s*/, "").trim());
83
101
  }
84
- /** Surveys the `.claude/` harness and `CLAUDE.md` at `dir`. Offline, read-only. */
85
- export function surveyHarness(dir) {
102
+ /** `CLAUDE.md`'s heading outline: `[]` when it is absent, or unreadable (recorded). */
103
+ function claudeMdHeadings(path, undetermined) {
104
+ if (!guardedExists(path, undetermined))
105
+ return [];
106
+ const content = readText(path, undetermined);
107
+ return content === undefined ? [] : extractHeadings(content);
108
+ }
109
+ /**
110
+ * Surveys the `.claude/` harness and `CLAUDE.md` at `dir`. Offline,
111
+ * read-only. A file or directory that exists but cannot be read
112
+ * (`EACCES`/`EPERM`) is left out of its collection and recorded in
113
+ * `undetermined` -- an unreadable `CLAUDE.md` still counts as present, with
114
+ * no headings. A `.claude/` this process may not enter is still `present`,
115
+ * with every collection empty and the directory recorded in `undetermined`
116
+ * -- never reported as an empty harness. Any other read failure throws, naming the path, with the
117
+ * original failure as `cause`.
118
+ *
119
+ * @example
120
+ * ```ts
121
+ * import { surveyHarness } from "./survey-harness.js";
122
+ *
123
+ * const undetermined: string[] = [];
124
+ * const harness = surveyHarness("/path/to/project", undetermined);
125
+ * console.log(harness.agents.map((agent) => agent.name), undetermined);
126
+ * ```
127
+ */
128
+ export function surveyHarness(dir, undetermined) {
86
129
  const claudeDir = join(dir, ".claude");
87
130
  const claudeMdPath = join(dir, "CLAUDE.md");
88
- const hasClaudeMd = existsSync(claudeMdPath);
89
- if (!existsSync(claudeDir)) {
131
+ const hasClaudeMd = guardedExists(claudeMdPath, undetermined);
132
+ if (!guardedExists(claudeDir, undetermined)) {
90
133
  return {
91
134
  present: false,
92
135
  settingsFile: undefined,
@@ -97,25 +140,23 @@ export function surveyHarness(dir) {
97
140
  commands: [],
98
141
  hasSettingsLocal: false,
99
142
  hasClaudeMd,
100
- claudeMdHeadings: hasClaudeMd
101
- ? extractHeadings(readFileSync(claudeMdPath, "utf8"))
102
- : [],
143
+ claudeMdHeadings: claudeMdHeadings(claudeMdPath, undetermined),
103
144
  };
104
145
  }
105
146
  const settingsPath = join(claudeDir, "settings.json");
106
147
  return {
107
148
  present: true,
108
- settingsFile: existsSync(settingsPath) ? "settings.json" : undefined,
109
- agents: surveyAgents(claudeDir),
110
- skills: surveySkills(claudeDir),
111
- hooks: surveyHooks(claudeDir),
112
- rules: surveyRules(claudeDir),
113
- commands: surveyCommands(claudeDir),
114
- hasSettingsLocal: existsSync(join(claudeDir, "settings.local.json")),
149
+ settingsFile: guardedExists(settingsPath, undetermined)
150
+ ? "settings.json"
151
+ : undefined,
152
+ agents: surveyAgents(claudeDir, undetermined),
153
+ skills: surveySkills(claudeDir, undetermined),
154
+ hooks: surveyHooks(claudeDir, undetermined),
155
+ rules: surveyRules(claudeDir, undetermined),
156
+ commands: surveyCommands(claudeDir, undetermined),
157
+ hasSettingsLocal: guardedExists(join(claudeDir, "settings.local.json"), undetermined),
115
158
  hasClaudeMd,
116
- claudeMdHeadings: hasClaudeMd
117
- ? extractHeadings(readFileSync(claudeMdPath, "utf8"))
118
- : [],
159
+ claudeMdHeadings: claudeMdHeadings(claudeMdPath, undetermined),
119
160
  };
120
161
  }
121
162
  //# sourceMappingURL=survey-harness.js.map
@@ -1,4 +1,19 @@
1
1
  import type { ShapeSurvey } from "./types.js";
2
- /** Surveys codebase shape at `dir`. Offline, read-only, records evidence rather than verdicts. */
3
- export declare function surveyShape(dir: string): ShapeSurvey;
2
+ /**
3
+ * Surveys codebase shape at `dir`. Offline, read-only, records evidence
4
+ * rather than verdicts. Appends anything it could not parse (a malformed
5
+ * `package.json`) or could not read (`EACCES`/`EPERM`) to `undetermined`;
6
+ * an absent file is not recorded. Any other read failure throws, naming the
7
+ * path, with the original failure as `cause`.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { surveyShape } from "./survey-shape.js";
12
+ *
13
+ * const undetermined: string[] = [];
14
+ * const shape = surveyShape("/path/to/project", undetermined);
15
+ * console.log(shape.packageManager, undetermined);
16
+ * ```
17
+ */
18
+ export declare function surveyShape(dir: string, undetermined: string[]): ShapeSurvey;
4
19
  //# sourceMappingURL=survey-shape.d.ts.map
@@ -1,12 +1,16 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Collects codebase-shape facts: package manager, monorepo tooling, module
3
5
  * system, pinned versions, source/test layout, and the evidence (not the
4
6
  * verdict) for what kind of project this is. `/customize`'s interview step
5
7
  * does the inferring; this module only records what is literally on disk.
6
8
  */
7
- import { existsSync, readFileSync, readdirSync } from "node:fs";
9
+ import { readFileSync, readdirSync } from "node:fs";
8
10
  import { join } from "node:path";
9
11
  import { walkBounded } from "./fs-walk.js";
12
+ import { readPackageJson } from "./internal/package-json.js";
13
+ import { guardedExists, guardedRead } from "./internal/read-guard.js";
10
14
  const FRAMEWORK_DEP_NAMES = [
11
15
  "react",
12
16
  "vue",
@@ -20,26 +24,14 @@ const FRAMEWORK_DEP_NAMES = [
20
24
  "@nestjs/core",
21
25
  "hono",
22
26
  ];
23
- function readPackageJson(dir) {
24
- const path = join(dir, "package.json");
25
- if (!existsSync(path)) {
26
- return undefined;
27
- }
28
- try {
29
- return JSON.parse(readFileSync(path, "utf8"));
30
- }
31
- catch {
32
- return undefined;
33
- }
34
- }
35
- function detectPackageManager(dir) {
36
- if (existsSync(join(dir, "pnpm-lock.yaml")))
27
+ function detectPackageManager(dir, undetermined) {
28
+ if (guardedExists(join(dir, "pnpm-lock.yaml"), undetermined))
37
29
  return "pnpm";
38
- if (existsSync(join(dir, "yarn.lock")))
30
+ if (guardedExists(join(dir, "yarn.lock"), undetermined))
39
31
  return "yarn";
40
- if (existsSync(join(dir, "bun.lockb")))
32
+ if (guardedExists(join(dir, "bun.lockb"), undetermined))
41
33
  return "bun";
42
- if (existsSync(join(dir, "package-lock.json")))
34
+ if (guardedExists(join(dir, "package-lock.json"), undetermined))
43
35
  return "npm";
44
36
  return "unknown";
45
37
  }
@@ -59,22 +51,24 @@ function extractYamlStringListUnder(content, key) {
59
51
  }
60
52
  return items;
61
53
  }
62
- function detectMonorepo(dir, packageJson) {
54
+ function detectMonorepo(dir, packageJson, undetermined) {
63
55
  const pnpmWorkspacePath = join(dir, "pnpm-workspace.yaml");
64
- if (existsSync(pnpmWorkspacePath)) {
65
- const content = readFileSync(pnpmWorkspacePath, "utf8");
56
+ if (guardedExists(pnpmWorkspacePath, undetermined)) {
57
+ const content = guardedRead(pnpmWorkspacePath, () => readFileSync(pnpmWorkspacePath, "utf8"), undetermined);
66
58
  return {
67
59
  tool: "pnpm-workspaces",
68
- globs: extractYamlStringListUnder(content, "packages"),
60
+ globs: content === undefined
61
+ ? []
62
+ : extractYamlStringListUnder(content, "packages"),
69
63
  };
70
64
  }
71
- if (existsSync(join(dir, "turbo.json"))) {
65
+ if (guardedExists(join(dir, "turbo.json"), undetermined)) {
72
66
  return { tool: "turbo", globs: [] };
73
67
  }
74
- if (existsSync(join(dir, "nx.json"))) {
68
+ if (guardedExists(join(dir, "nx.json"), undetermined)) {
75
69
  return { tool: "nx", globs: [] };
76
70
  }
77
- if (existsSync(join(dir, "lerna.json"))) {
71
+ if (guardedExists(join(dir, "lerna.json"), undetermined)) {
78
72
  return { tool: "lerna", globs: [] };
79
73
  }
80
74
  const workspaces = packageJson?.["workspaces"];
@@ -106,15 +100,18 @@ function detectTypescriptVersion(packageJson) {
106
100
  const value = fromDevDeps ?? fromDeps;
107
101
  return typeof value === "string" ? value : undefined;
108
102
  }
109
- function detectNodeVersionPin(dir, packageJson) {
103
+ function detectNodeVersionPin(dir, packageJson, undetermined) {
110
104
  for (const [source, filename] of [
111
105
  [".node-version", ".node-version"],
112
106
  [".nvmrc", ".nvmrc"],
113
107
  ]) {
114
108
  const path = join(dir, filename);
115
- if (existsSync(path)) {
116
- return { source, value: readFileSync(path, "utf8").trim() };
117
- }
109
+ if (!guardedExists(path, undetermined))
110
+ continue;
111
+ // An unreadable pin file falls through to the next source.
112
+ const content = guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
113
+ if (content !== undefined)
114
+ return { source, value: content.trim() };
118
115
  }
119
116
  const engines = packageJson?.["engines"];
120
117
  const nodeEngine = typeof engines === "object" && engines !== null
@@ -125,24 +122,26 @@ function detectNodeVersionPin(dir, packageJson) {
125
122
  }
126
123
  return undefined;
127
124
  }
128
- function detectSourceLayout(dir) {
129
- if (existsSync(join(dir, "src")))
125
+ function detectSourceLayout(dir, undetermined) {
126
+ if (guardedExists(join(dir, "src"), undetermined))
130
127
  return "src";
131
- if (existsSync(join(dir, "lib")))
128
+ if (guardedExists(join(dir, "lib"), undetermined))
132
129
  return "lib";
133
- const rootEntries = readdirSync(dir).filter((name) => /\.(ts|tsx|js|mjs)$/.test(name));
130
+ const names = guardedRead(dir, () => readdirSync(dir), undetermined) ?? [];
131
+ const rootEntries = names.filter((name) => /\.(ts|tsx|js|mjs)$/.test(name));
134
132
  if (rootEntries.length > 0)
135
133
  return "root";
136
134
  return "unknown";
137
135
  }
138
- function hasColocatedTests(dir) {
139
- return walkBounded(dir, 2).some((entry) => !entry.isDirectory && /\.(test|spec)\.[jt]sx?$/.test(entry.relPath));
136
+ function hasColocatedTests(dir, undetermined) {
137
+ return walkBounded(dir, 2, undetermined).some((entry) => !entry.isDirectory && /\.(test|spec)\.[jt]sx?$/.test(entry.relPath));
140
138
  }
141
- function detectTestPlacement(dir) {
142
- if (existsSync(join(dir, "tests")) || existsSync(join(dir, "test"))) {
139
+ function detectTestPlacement(dir, undetermined) {
140
+ if (guardedExists(join(dir, "tests"), undetermined) ||
141
+ guardedExists(join(dir, "test"), undetermined)) {
143
142
  return "tests-dir";
144
143
  }
145
- if (hasColocatedTests(dir)) {
144
+ if (hasColocatedTests(dir, undetermined)) {
146
145
  return "colocated";
147
146
  }
148
147
  return "unknown";
@@ -163,19 +162,34 @@ function collectKindEvidence(packageJson) {
163
162
  frameworkDeps: FRAMEWORK_DEP_NAMES.filter((name) => allDepNames.has(name)),
164
163
  };
165
164
  }
166
- /** Surveys codebase shape at `dir`. Offline, read-only, records evidence rather than verdicts. */
167
- export function surveyShape(dir) {
168
- const packageJson = readPackageJson(dir);
169
- const monorepo = detectMonorepo(dir, packageJson);
165
+ /**
166
+ * Surveys codebase shape at `dir`. Offline, read-only, records evidence
167
+ * rather than verdicts. Appends anything it could not parse (a malformed
168
+ * `package.json`) or could not read (`EACCES`/`EPERM`) to `undetermined`;
169
+ * an absent file is not recorded. Any other read failure throws, naming the
170
+ * path, with the original failure as `cause`.
171
+ *
172
+ * @example
173
+ * ```ts
174
+ * import { surveyShape } from "./survey-shape.js";
175
+ *
176
+ * const undetermined: string[] = [];
177
+ * const shape = surveyShape("/path/to/project", undetermined);
178
+ * console.log(shape.packageManager, undetermined);
179
+ * ```
180
+ */
181
+ export function surveyShape(dir, undetermined) {
182
+ const packageJson = readPackageJson(dir, undetermined);
183
+ const monorepo = detectMonorepo(dir, packageJson, undetermined);
170
184
  return {
171
- packageManager: detectPackageManager(dir),
185
+ packageManager: detectPackageManager(dir, undetermined),
172
186
  monorepoTool: monorepo.tool,
173
187
  workspaceGlobs: monorepo.globs,
174
188
  moduleType: detectModuleType(packageJson),
175
189
  typescriptVersion: detectTypescriptVersion(packageJson),
176
- nodeVersionPin: detectNodeVersionPin(dir, packageJson),
177
- sourceLayout: detectSourceLayout(dir),
178
- testPlacement: detectTestPlacement(dir),
190
+ nodeVersionPin: detectNodeVersionPin(dir, packageJson, undetermined),
191
+ sourceLayout: detectSourceLayout(dir, undetermined),
192
+ testPlacement: detectTestPlacement(dir, undetermined),
179
193
  kindEvidence: collectKindEvidence(packageJson),
180
194
  };
181
195
  }
@@ -1,4 +1,19 @@
1
1
  import type { ToolchainSurvey } from "./types.js";
2
- /** Surveys toolchain enforcement at `dir`. Appends anything it could not parse to `undetermined`. */
2
+ /**
3
+ * Surveys toolchain enforcement at `dir`. Appends anything it could not
4
+ * parse, or could not read (`EACCES`/`EPERM`, a dangling symlink, a symlink
5
+ * loop, a directory where a file was expected), to `undetermined`; any other
6
+ * read failure throws, naming the path, with the original failure as
7
+ * `cause`.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { surveyToolchain } from "./survey-toolchain.js";
12
+ *
13
+ * const undetermined: string[] = [];
14
+ * const toolchain = surveyToolchain("/path/to/project", undetermined);
15
+ * console.log(toolchain.tsconfig.effectiveFlags, undetermined);
16
+ * ```
17
+ */
3
18
  export declare function surveyToolchain(dir: string, undetermined: string[]): ToolchainSurvey;
4
19
  //# sourceMappingURL=survey-toolchain.d.ts.map
@@ -1,3 +1,5 @@
1
+ // SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
2
+ // SPDX-License-Identifier: MIT
1
3
  /**
2
4
  * Collects toolchain-enforcement facts: the tsconfig `extends` chain and its
3
5
  * effective strict-family flags, which eslint/test/formatter/git-hook tool
@@ -6,28 +8,19 @@
6
8
  * here, never parsed -- see `needsReading` on each -- because this package
7
9
  * carries no YAML dependency.
8
10
  */
9
- import { existsSync, readFileSync, readdirSync } from "node:fs";
11
+ import { readFileSync, readdirSync } from "node:fs";
10
12
  import { join } from "node:path";
11
13
  import { STRICT_FLAGS } from "../toolchain/rules.js";
12
14
  import { loadTsconfigChain } from "../toolchain/tsconfig-chain.js";
15
+ import { readPackageJson } from "./internal/package-json.js";
16
+ import { guardedExists, guardedRead } from "./internal/read-guard.js";
13
17
  /** Every flag the toolchain grader judges, so `effectiveFlags` and the grade cannot disagree. */
14
18
  const STRICT_FLAG_NAMES = [...STRICT_FLAGS, "allowUnreachableCode"];
15
19
  const ESLINT_PLUGIN_PATTERN = /["']((?:eslint-plugin-|@typescript-eslint\/)[a-z0-9-]+)["']/gi;
16
- function readPackageJson(dir) {
17
- const path = join(dir, "package.json");
18
- if (!existsSync(path))
19
- return undefined;
20
- try {
21
- return JSON.parse(readFileSync(path, "utf8"));
22
- }
23
- catch {
24
- return undefined;
25
- }
26
- }
27
- function findTsconfigPath(dir) {
20
+ function findTsconfigPath(dir, undetermined) {
28
21
  for (const name of ["tsconfig.json"]) {
29
22
  const path = join(dir, name);
30
- if (existsSync(path))
23
+ if (guardedExists(path, undetermined))
31
24
  return path;
32
25
  }
33
26
  return undefined;
@@ -37,11 +30,12 @@ function findTsconfigPath(dir) {
37
30
  * shares, so the survey and the grade cannot disagree about a project's
38
31
  * effective flags. `files` are absolute and child-first. A file that fails to
39
32
  * parse, or a relative `extends` that points at nothing, is a parse failure; a
40
- * bare package specifier that is not installed is only a note -- the flags it
33
+ * file in the chain that exists but cannot be read is noted "could not read",
34
+ * never "could not parse"; a bare package specifier that is not installed is only a note -- the flags it
41
35
  * would contribute are simply absent.
42
36
  */
43
37
  function surveyTsconfig(dir, undetermined) {
44
- const entryPath = findTsconfigPath(dir);
38
+ const entryPath = findTsconfigPath(dir, undetermined);
45
39
  if (entryPath === undefined) {
46
40
  return { files: [], effectiveFlags: {}, parsed: false };
47
41
  }
@@ -49,7 +43,8 @@ function surveyTsconfig(dir, undetermined) {
49
43
  let parsed = chain.parsed;
50
44
  for (const file of chain.files) {
51
45
  if (file.error !== undefined) {
52
- undetermined.push(`could not parse ${file.abs}: ${file.error}`);
46
+ const verb = file.readFailed ? "could not read" : "could not parse";
47
+ undetermined.push(`${verb} ${file.abs}: ${file.error}`);
53
48
  }
54
49
  }
55
50
  for (const link of chain.links) {
@@ -71,39 +66,30 @@ function surveyTsconfig(dir, undetermined) {
71
66
  }
72
67
  return { files: chain.files.map((file) => file.abs), effectiveFlags, parsed };
73
68
  }
74
- function surveyEslint(dir) {
75
- const flatCandidates = [
76
- "eslint.config.js",
77
- "eslint.config.mjs",
78
- "eslint.config.ts",
79
- ];
80
- const legacyCandidates = [
81
- ".eslintrc.js",
82
- ".eslintrc.cjs",
83
- ".eslintrc.json",
84
- ".eslintrc",
85
- ];
86
- for (const name of flatCandidates) {
87
- const path = join(dir, name);
88
- if (existsSync(path)) {
89
- const content = readFileSync(path, "utf8");
90
- return {
91
- configFile: name,
92
- flat: true,
93
- referencedPlugins: extractPluginNames(content),
94
- };
95
- }
96
- }
97
- for (const name of legacyCandidates) {
69
+ const ESLINT_CANDIDATES = [
70
+ ["eslint.config.js", true],
71
+ ["eslint.config.mjs", true],
72
+ ["eslint.config.ts", true],
73
+ [".eslintrc.js", false],
74
+ [".eslintrc.cjs", false],
75
+ [".eslintrc.json", false],
76
+ [".eslintrc", false],
77
+ ];
78
+ /**
79
+ * The first ESLint config present, flat configs first. An unreadable config
80
+ * is still reported (its presence is known) with no plugins, and recorded.
81
+ */
82
+ function surveyEslint(dir, undetermined) {
83
+ for (const [name, flat] of ESLINT_CANDIDATES) {
98
84
  const path = join(dir, name);
99
- if (existsSync(path)) {
100
- const content = readFileSync(path, "utf8");
101
- return {
102
- configFile: name,
103
- flat: false,
104
- referencedPlugins: extractPluginNames(content),
105
- };
106
- }
85
+ if (!guardedExists(path, undetermined))
86
+ continue;
87
+ const content = guardedRead(path, () => readFileSync(path, "utf8"), undetermined);
88
+ return {
89
+ configFile: name,
90
+ flat,
91
+ referencedPlugins: content === undefined ? [] : extractPluginNames(content),
92
+ };
107
93
  }
108
94
  return { configFile: undefined, flat: false, referencedPlugins: [] };
109
95
  }
@@ -116,7 +102,7 @@ function extractPluginNames(content) {
116
102
  }
117
103
  return [...names];
118
104
  }
119
- function surveyTestRunner(dir, scripts) {
105
+ function surveyTestRunner(dir, scripts, undetermined) {
120
106
  const candidates = [
121
107
  ["vitest.config.ts", "vitest"],
122
108
  ["vitest.config.js", "vitest"],
@@ -128,7 +114,7 @@ function surveyTestRunner(dir, scripts) {
128
114
  [".mocharc.js", "mocha"],
129
115
  ];
130
116
  for (const [name, tool] of candidates) {
131
- if (existsSync(join(dir, name))) {
117
+ if (guardedExists(join(dir, name), undetermined)) {
132
118
  return { tool, configFile: name };
133
119
  }
134
120
  }
@@ -138,7 +124,7 @@ function surveyTestRunner(dir, scripts) {
138
124
  }
139
125
  return { tool: "unknown", configFile: undefined };
140
126
  }
141
- function surveyFormatter(dir) {
127
+ function surveyFormatter(dir, undetermined) {
142
128
  const prettierCandidates = [
143
129
  ".prettierrc.json",
144
130
  ".prettierrc.js",
@@ -147,29 +133,29 @@ function surveyFormatter(dir) {
147
133
  ".prettierrc.yml",
148
134
  ];
149
135
  for (const name of prettierCandidates) {
150
- if (existsSync(join(dir, name))) {
136
+ if (guardedExists(join(dir, name), undetermined)) {
151
137
  return { tool: "prettier", configFile: name };
152
138
  }
153
139
  }
154
- if (existsSync(join(dir, "biome.json"))) {
140
+ if (guardedExists(join(dir, "biome.json"), undetermined)) {
155
141
  return { tool: "biome", configFile: "biome.json" };
156
142
  }
157
143
  return { tool: "unknown", configFile: undefined };
158
144
  }
159
145
  function surveyGitHooks(dir, undetermined) {
160
- if (existsSync(join(dir, "lefthook.yml")) ||
161
- existsSync(join(dir, "lefthook.yaml"))) {
162
- const configFile = existsSync(join(dir, "lefthook.yml"))
146
+ if (guardedExists(join(dir, "lefthook.yml"), undetermined) ||
147
+ guardedExists(join(dir, "lefthook.yaml"), undetermined)) {
148
+ const configFile = guardedExists(join(dir, "lefthook.yml"), undetermined)
163
149
  ? "lefthook.yml"
164
150
  : "lefthook.yaml";
165
151
  undetermined.push(`${configFile} found -- its stage commands need reading, not parsing`);
166
152
  return { manager: "lefthook", configFile, needsReading: true };
167
153
  }
168
- if (existsSync(join(dir, ".husky"))) {
154
+ if (guardedExists(join(dir, ".husky"), undetermined)) {
169
155
  undetermined.push(".husky/ found -- its hook scripts need reading, not parsing");
170
156
  return { manager: "husky", configFile: ".husky", needsReading: true };
171
157
  }
172
- if (existsSync(join(dir, "simple-git-hooks.json"))) {
158
+ if (guardedExists(join(dir, "simple-git-hooks.json"), undetermined)) {
173
159
  return {
174
160
  manager: "simple-git-hooks",
175
161
  configFile: "simple-git-hooks.json",
@@ -180,10 +166,12 @@ function surveyGitHooks(dir, undetermined) {
180
166
  }
181
167
  function surveyWorkflows(dir, undetermined) {
182
168
  const workflowsDir = join(dir, ".github", "workflows");
183
- if (!existsSync(workflowsDir)) {
169
+ if (!guardedExists(workflowsDir, undetermined)) {
184
170
  return { files: [], needsReading: false };
185
171
  }
186
- const files = readdirSync(workflowsDir).filter((name) => /\.ya?ml$/.test(name));
172
+ const names = guardedRead(workflowsDir, () => readdirSync(workflowsDir), undetermined) ??
173
+ [];
174
+ const files = names.filter((name) => /\.ya?ml$/.test(name));
187
175
  if (files.length > 0) {
188
176
  undetermined.push(`${files.length} workflow file(s) under .github/workflows -- their job steps need reading, not parsing`);
189
177
  }
@@ -200,15 +188,30 @@ function surveyScripts(packageJson) {
200
188
  }
201
189
  return result;
202
190
  }
203
- /** Surveys toolchain enforcement at `dir`. Appends anything it could not parse to `undetermined`. */
191
+ /**
192
+ * Surveys toolchain enforcement at `dir`. Appends anything it could not
193
+ * parse, or could not read (`EACCES`/`EPERM`, a dangling symlink, a symlink
194
+ * loop, a directory where a file was expected), to `undetermined`; any other
195
+ * read failure throws, naming the path, with the original failure as
196
+ * `cause`.
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * import { surveyToolchain } from "./survey-toolchain.js";
201
+ *
202
+ * const undetermined: string[] = [];
203
+ * const toolchain = surveyToolchain("/path/to/project", undetermined);
204
+ * console.log(toolchain.tsconfig.effectiveFlags, undetermined);
205
+ * ```
206
+ */
204
207
  export function surveyToolchain(dir, undetermined) {
205
- const packageJson = readPackageJson(dir);
208
+ const packageJson = readPackageJson(dir, undetermined);
206
209
  const scripts = surveyScripts(packageJson);
207
210
  return {
208
211
  tsconfig: surveyTsconfig(dir, undetermined),
209
- eslint: surveyEslint(dir),
210
- testRunner: surveyTestRunner(dir, scripts),
211
- formatter: surveyFormatter(dir),
212
+ eslint: surveyEslint(dir, undetermined),
213
+ testRunner: surveyTestRunner(dir, scripts, undetermined),
214
+ formatter: surveyFormatter(dir, undetermined),
212
215
  gitHooks: surveyGitHooks(dir, undetermined),
213
216
  workflows: surveyWorkflows(dir, undetermined),
214
217
  scripts,