@boyingliu01/xp-gate 0.19.3 → 0.20.0

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 (173) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +49 -5
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/typescript.sh +15 -60
  5. package/bin/xp-gate.js +15 -16
  6. package/gate-10.sh +35 -2
  7. package/gate-4.sh +110 -21
  8. package/gate-8.sh +49 -5
  9. package/hooks/gate-10.sh +35 -2
  10. package/hooks/gate-4.sh +110 -21
  11. package/hooks/gate-8.sh +49 -5
  12. package/hooks/lib/jscpd-run.sh +63 -0
  13. package/hooks/lib/sprint-gate-report.sh +58 -0
  14. package/hooks/lib/test-failure.sh +152 -0
  15. package/hooks/lib/typecheck.sh +141 -0
  16. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  17. package/hooks/post-merge +100 -0
  18. package/hooks/pre-commit +389 -162
  19. package/hooks/pre-push +75 -19
  20. package/lib/__tests__/bootstrap.test.js +23 -41
  21. package/lib/__tests__/check-version.test.js +8 -1
  22. package/lib/__tests__/detect-deps.test.js +16 -0
  23. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  24. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  25. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  26. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  27. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  28. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  30. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  31. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  32. package/lib/__tests__/doctor.test.js +155 -0
  33. package/lib/__tests__/gate-runner.test.js +19 -3
  34. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  35. package/lib/__tests__/init.test.js +44 -17
  36. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  37. package/lib/__tests__/install-skill.test.js +51 -132
  38. package/lib/__tests__/language-tools-detect.test.js +158 -0
  39. package/lib/__tests__/phase-transition.test.js +15 -1
  40. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  41. package/lib/__tests__/shared-paths.test.js +20 -0
  42. package/lib/__tests__/shared-utils.test.js +69 -0
  43. package/lib/__tests__/skill-flags.test.js +48 -0
  44. package/lib/__tests__/ui-detector.test.ts +6 -2
  45. package/lib/__tests__/uninstall.test.js +20 -0
  46. package/lib/__tests__/update-hooks.test.js +12 -0
  47. package/lib/__tests__/update-skill.test.js +7 -4
  48. package/lib/detect-deps.js +3 -20
  49. package/lib/doctor-tui.js +22 -5
  50. package/lib/doctor.js +1178 -31
  51. package/lib/init.js +184 -33
  52. package/lib/install-cmd.js +7 -4
  53. package/lib/install-skill.js +38 -95
  54. package/lib/language-tools.js +83 -4
  55. package/lib/mattpocock-license-verdict.json +1 -1
  56. package/lib/shared-paths.js +5 -2
  57. package/lib/shared-utils.js +47 -3
  58. package/lib/uninstall.js +12 -0
  59. package/lib/update-hooks.js +4 -4
  60. package/lib/update-skill.js +6 -7
  61. package/mock-policy/AGENTS.md +4 -4
  62. package/mutation/AGENTS.md +4 -4
  63. package/mutation/__tests__/gate-m.test.ts +66 -14
  64. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  65. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  66. package/mutation/gate-m.ts +21 -8
  67. package/package.json +1 -1
  68. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  69. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  70. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  71. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  72. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  73. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  74. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  76. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  77. package/plugins/dsh/README.md +169 -4
  78. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  79. package/plugins/dsh/package.json +1 -1
  80. package/plugins/dsh/scripts/prepack.cjs +24 -19
  81. package/plugins/dsh/setup-dsh.ps1 +160 -0
  82. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  84. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  85. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  86. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  87. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  89. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  90. package/plugins/dsh/src/command.test.ts +66 -2
  91. package/plugins/dsh/src/command.ts +36 -14
  92. package/plugins/dsh/src/contract.test.ts +31 -1
  93. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  94. package/plugins/dsh/src/delphi-config.ts +223 -0
  95. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  96. package/plugins/dsh/src/delphi-run.ts +129 -0
  97. package/plugins/dsh/src/delphi.test.ts +222 -0
  98. package/plugins/dsh/src/delphi.ts +247 -0
  99. package/plugins/dsh/src/dialect.test.ts +59 -0
  100. package/plugins/dsh/src/dialect.ts +40 -0
  101. package/plugins/dsh/src/index.ts +70 -4
  102. package/plugins/dsh/src/skills.test.ts +26 -19
  103. package/plugins/opencode/package.json +1 -1
  104. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  105. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  106. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  107. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  108. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  109. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  111. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  112. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  113. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  114. package/plugins/qoder/agents/delphi-technical.md +1 -1
  115. package/plugins/qoder/plugin.json +1 -1
  116. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  117. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  118. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  119. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  120. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  123. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  124. package/principles/AGENTS.md +9 -8
  125. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  126. package/principles/__tests__/analyzer.test.ts +29 -6
  127. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  128. package/principles/__tests__/baseline-prune.test.ts +338 -0
  129. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  130. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  131. package/principles/__tests__/boy-scout.test.ts +253 -7
  132. package/principles/__tests__/config-enforcement.test.ts +469 -0
  133. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  134. package/principles/__tests__/direct-execution.test.ts +187 -0
  135. package/principles/__tests__/index.test.ts +16 -3
  136. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  137. package/principles/adapters/typescript.ts +613 -13
  138. package/principles/analyzer.ts +22 -0
  139. package/principles/baseline-prune.ts +217 -0
  140. package/principles/boy-scout.ts +481 -51
  141. package/principles/config.ts +362 -54
  142. package/principles/direct-execution.ts +60 -0
  143. package/principles/index.ts +40 -17
  144. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  145. package/principles/rules/clean-code/code-duplication.ts +8 -8
  146. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  147. package/principles/rules/clean-code/god-class.ts +8 -8
  148. package/principles/rules/clean-code/large-file.ts +15 -11
  149. package/principles/rules/clean-code/long-function.ts +8 -8
  150. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  151. package/principles/rules/clean-code/many-exports.ts +7 -7
  152. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  153. package/principles/rules/clean-code/too-many-params.ts +8 -8
  154. package/principles/rules/clean-code/unused-imports.ts +5 -5
  155. package/principles/rules/solid/dip.ts +18 -8
  156. package/principles/rules/solid/isp.ts +9 -8
  157. package/principles/rules/solid/lsp.ts +6 -6
  158. package/principles/rules/solid/ocp.ts +6 -5
  159. package/principles/rules/solid/srp.ts +7 -8
  160. package/principles/types.ts +15 -0
  161. package/scripts/__tests__/sync-package-content.test.js +28 -0
  162. package/scripts/delphi-external-review.cjs +193 -10
  163. package/scripts/sync-package-content.js +7 -4
  164. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  165. package/skills/delphi-review/AGENTS.md +4 -4
  166. package/skills/delphi-review/SKILL.md +98 -14
  167. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  168. package/skills/delphi-review/references/requirements.md +6 -6
  169. package/skills/sprint-flow/AGENTS.md +4 -4
  170. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  171. package/skills/test-specification-alignment/AGENTS.md +4 -4
  172. package/lib/__tests__/download-skill.test.js +0 -281
  173. package/lib/download-skill.js +0 -96
@@ -1,8 +1,8 @@
1
- import { readFile } from 'fs/promises';
2
- import { existsSync } from 'fs';
3
- import { join } from 'path';
1
+ import { execFileSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { join, resolve } from 'path';
4
4
 
5
- interface RuleConfig {
5
+ export interface RuleConfig {
6
6
  enabled: boolean;
7
7
  threshold?: number;
8
8
  exclude?: (string | number)[];
@@ -43,36 +43,89 @@ interface PrinciplesConfig {
43
43
  };
44
44
  }
45
45
 
46
+ /** Config keys that carry a numeric threshold, per group. */
47
+ const NUMERIC_KEYS = ['threshold', 'methodThreshold'] as const;
48
+
49
+ /**
50
+ * Ceiling on any single git call made while resolving config. This runs inside a
51
+ * pre-commit hook, so an unbounded wait is not a slow commit but a hung one.
52
+ */
53
+ const GIT_TIMEOUT_MS = 15_000;
54
+
55
+ /** Config keys that must remain arrays. */
56
+ const ARRAY_KEYS = ['exclude'] as const;
57
+
58
+ /**
59
+ * `severity` is not a free-form string: `summary.errorCount` and Gate 4's report
60
+ * grep both match the exact lowercase literal `error`, so anything else -- "Error",
61
+ * "fatal" -- runs the rule, reports the violation, and blocks nothing. Validated
62
+ * against the vocabulary rather than treated as an opaque string.
63
+ */
64
+ const SEVERITIES = ['error', 'warning', 'info'] as const;
65
+
66
+ /** Config keys whose value must be one of a fixed set, with that set. */
67
+ const ENUM_KEYS = { severity: SEVERITIES } as const;
68
+
69
+ /**
70
+ * Built-in defaults, hoisted out of `getDefaultConfig()`.
71
+ *
72
+ * Kept as module constants so the factory stays a single return statement and
73
+ * below the deep-nesting threshold (clean-code.deep-nesting), and so the two
74
+ * rule groups are readable side by side.
75
+ */
76
+ const DEFAULT_CLEAN_CODE_RULES = {
77
+ 'long-function': { enabled: true, threshold: 50, severity: 'warning' },
78
+ 'large-file': { enabled: true, threshold: 1150, severity: 'warning' },
79
+ 'god-class': { enabled: true, threshold: 15, severity: 'warning' },
80
+ 'deep-nesting': { enabled: true, threshold: 4, severity: 'warning' },
81
+ 'too-many-params': { enabled: true, threshold: 7, severity: 'info' },
82
+ 'magic-numbers': {
83
+ enabled: true,
84
+ exclude: [0, 1, -1, 2, 10, 100, 1000, 60, 24, 7, 30, 365, 256, 1024],
85
+ severity: 'info'
86
+ },
87
+ 'missing-error-handling': { enabled: true, severity: 'warning' },
88
+ 'unused-imports': { enabled: true, severity: 'info' },
89
+ 'code-duplication': { enabled: true, threshold: 15, severity: 'warning' },
90
+ 'many-exports': { enabled: true, threshold: 10, severity: 'warning' }
91
+ } satisfies PrinciplesConfig['rules']['clean-code'];
92
+
93
+ const DEFAULT_SOLID_RULES = {
94
+ srp: { enabled: true, methodThreshold: 15, severity: 'warning' },
95
+ ocp: { enabled: true, severity: 'info' },
96
+ lsp: { enabled: true, severity: 'info' },
97
+ isp: { enabled: true, methodThreshold: 10, severity: 'info' },
98
+ dip: {
99
+ enabled: true,
100
+ exclude: ['Date', 'Map', 'Set', 'Error', 'Array', 'Object', 'Promise'],
101
+ severity: 'warning'
102
+ }
103
+ } satisfies PrinciplesConfig['rules']['solid'];
104
+
105
+ /**
106
+ * Deep-clone a rule group so callers cannot mutate the module constants.
107
+ *
108
+ * Without this, `getDefaultConfig()` would hand out references to
109
+ * `DEFAULT_CLEAN_CODE_RULES` / `DEFAULT_SOLID_RULES` and a caller that tweaks
110
+ * one rule (e.g. `config.rules['clean-code']['large-file'].threshold = 10`)
111
+ * would permanently corrupt the defaults for every later caller in the process.
112
+ */
113
+ function cloneRules<T extends Record<string, RuleConfig>>(rules: T): T {
114
+ const copy: Record<string, RuleConfig> = {};
115
+ for (const [ruleId, rule] of Object.entries(rules)) {
116
+ copy[ruleId] = {
117
+ ...rule,
118
+ ...(rule.exclude ? { exclude: [...rule.exclude] } : {})
119
+ };
120
+ }
121
+ return copy as T;
122
+ }
123
+
46
124
  export function getDefaultConfig(): PrinciplesConfig {
47
125
  return {
48
126
  rules: {
49
- 'clean-code': {
50
- 'long-function': { enabled: true, threshold: 50, severity: 'warning' },
51
- 'large-file': { enabled: true, threshold: 1150, severity: 'warning' },
52
- 'god-class': { enabled: true, threshold: 15, severity: 'warning' },
53
- 'deep-nesting': { enabled: true, threshold: 4, severity: 'warning' },
54
- 'too-many-params': { enabled: true, threshold: 7, severity: 'info' },
55
- 'magic-numbers': {
56
- enabled: true,
57
- exclude: [0, 1, -1, 2, 10, 100, 1000, 60, 24, 7, 30, 365, 256, 1024],
58
- severity: 'info'
59
- },
60
- 'missing-error-handling': { enabled: true, severity: 'warning' },
61
- 'unused-imports': { enabled: true, severity: 'info' },
62
- 'code-duplication': { enabled: true, threshold: 15, severity: 'warning' },
63
- 'many-exports': { enabled: true, threshold: 10, severity: 'warning' }
64
- },
65
- 'solid': {
66
- 'srp': { enabled: true, methodThreshold: 15, severity: 'warning' },
67
- 'ocp': { enabled: true, severity: 'info' },
68
- 'lsp': { enabled: true, severity: 'info' },
69
- 'isp': { enabled: true, methodThreshold: 10, severity: 'info' },
70
- 'dip': {
71
- enabled: true,
72
- exclude: ['Date', 'Map', 'Set', 'Error', 'Array', 'Object', 'Promise'],
73
- severity: 'warning'
74
- }
75
- }
127
+ 'clean-code': cloneRules(DEFAULT_CLEAN_CODE_RULES),
128
+ 'solid': cloneRules(DEFAULT_SOLID_RULES)
76
129
  },
77
130
  output: {
78
131
  format: 'console',
@@ -86,33 +139,288 @@ export function getDefaultConfig(): PrinciplesConfig {
86
139
  };
87
140
  }
88
141
 
89
- export async function loadConfig(): Promise<PrinciplesConfig> {
90
- const defaultConfig = getDefaultConfig();
91
- const configPath = join(process.cwd(), '.principlesrc');
142
+ /**
143
+ * Load `.principlesrc`, falling back to built-in defaults.
144
+ *
145
+ * Resolution order when `configPath` is omitted:
146
+ * 1. `<git toplevel>/.principlesrc` -- so Gate 4 behaves the same regardless
147
+ * of the process's cwd.
148
+ * 2. `<cwd>/.principlesrc` -- when git is unavailable (non-repo, broken
149
+ * shallow checkout, sandboxed CI).
150
+ *
151
+ * A missing, unreadable, or malformed file never throws: the built-in defaults
152
+ * are returned and a warning is printed. Invalid *values* (a string where a
153
+ * number belongs, etc.) keep the default for that key rather than poisoning the
154
+ * rule with a value it cannot compare against.
155
+ *
156
+ * One exception, for a path the CALLER named: if `configPath` is passed and that
157
+ * file does not exist, loading fails. "The project has no `.principlesrc`" is a
158
+ * normal state, but "the project declared one and it cannot be found" is a
159
+ * misconfiguration, and answering it with built-in thresholds re-creates exactly
160
+ * the fail-open gate this module exists to close (#457, walkthrough FC-08).
161
+ */
162
+ export async function loadConfig(configPath?: string): Promise<PrinciplesConfig> {
163
+ const defaults = getDefaultConfig();
164
+ const resolvedPath = configPath ? resolve(configPath) : resolveDefaultConfigPath();
92
165
 
93
- if (!existsSync(configPath)) {
94
- return defaultConfig;
166
+ if (!resolvedPath || !existsSync(resolvedPath)) {
167
+ if (configPath) {
168
+ throw new Error(`config file not found: ${resolvedPath}`);
169
+ }
170
+ return defaults;
95
171
  }
96
172
 
173
+ let parsed: unknown;
97
174
  try {
98
- const content = await readFile(configPath, 'utf-8');
99
- const userConfig = JSON.parse(content) as Partial<PrinciplesConfig>;
100
-
101
- return {
102
- ...defaultConfig,
103
- ...userConfig,
104
- rules: {
105
- 'clean-code': {
106
- ...defaultConfig.rules['clean-code'],
107
- ...userConfig.rules?.['clean-code']
108
- },
109
- 'solid': {
110
- ...defaultConfig.rules['solid'],
111
- ...userConfig.rules?.['solid']
112
- }
113
- }
114
- };
175
+ parsed = JSON.parse(readFileSync(resolvedPath, 'utf-8'));
176
+ } catch (error) {
177
+ warn(`could not parse ${resolvedPath}: ${describeError(error)} — using built-in defaults`);
178
+ return defaults;
179
+ }
180
+
181
+ if (!isPlainObject(parsed)) {
182
+ warn(`${resolvedPath} must contain a JSON object — using built-in defaults`);
183
+ return defaults;
184
+ }
185
+
186
+ return mergeConfig(defaults, parsed as Partial<PrinciplesConfig>);
187
+ }
188
+
189
+ /** Resolve the default config path: git toplevel first, then cwd. */
190
+ function resolveDefaultConfigPath(): string | null {
191
+ const toplevel = gitToplevel();
192
+ if (toplevel) {
193
+ const candidate = join(toplevel, '.principlesrc');
194
+ if (existsSync(candidate)) return candidate;
195
+ }
196
+ return join(process.cwd(), '.principlesrc');
197
+ }
198
+
199
+ /**
200
+ * `git rev-parse --show-toplevel`, or null when git is unavailable.
201
+ *
202
+ * Deliberately swallows failure: this runs in consumer projects, non-repo
203
+ * directories, and CI sandboxes where git may be absent or the checkout shallow.
204
+ * Callers fall back to cwd.
205
+ */
206
+ function gitToplevel(): string | null {
207
+ try {
208
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
209
+ encoding: 'utf-8',
210
+ stdio: ['ignore', 'pipe', 'ignore'],
211
+ // Bounded like every other git call that runs inside a hook. Without it a
212
+ // wedged git (locked index, stalled credential prompt, network-backed fs) hangs
213
+ // the commit -- and this path is taken by every Gate 4 run, since gate-4.sh
214
+ // passes no --config. Raised by the Delphi walkthrough (MAJ-01), which noticed
215
+ // the timeout added to baseline-prune.ts had not been applied here too.
216
+ timeout: GIT_TIMEOUT_MS,
217
+ }).trim();
218
+ return out.length > 0 ? out : null;
115
219
  } catch {
116
- return defaultConfig;
220
+ return null;
221
+ }
222
+ }
223
+
224
+ /** Deep-merge a user config over the defaults, validating each value's shape. */
225
+ function mergeConfig(
226
+ defaults: PrinciplesConfig,
227
+ user: Partial<PrinciplesConfig>
228
+ ): PrinciplesConfig {
229
+ return {
230
+ ...defaults,
231
+ ...user,
232
+ // `rules` needs the group-aware merge below. The other sections are plain
233
+ // option bags, and spreading `...user` alone would let a partial block wipe
234
+ // its sibling defaults -- naming one key under `output` silently discarded
235
+ // `show-score` and `colorize`. Same fail-open shape #457 exists to remove,
236
+ // just outside `rules`; found by the Delphi walkthrough.
237
+ output: mergeSection(defaults.output, user.output),
238
+ performance: mergeSection(defaults.performance, user.performance),
239
+ rules: {
240
+ 'clean-code': mergeGroup(
241
+ defaults.rules['clean-code'],
242
+ user.rules?.['clean-code']
243
+ ),
244
+ 'solid': mergeGroup(defaults.rules['solid'], user.rules?.['solid'])
245
+ }
246
+ };
247
+ }
248
+
249
+ /** Merge one flat options section, keeping defaults for keys the user omitted. */
250
+ function mergeSection<T extends object>(
251
+ defaults: T | undefined,
252
+ user: Partial<T> | undefined
253
+ ): T | undefined {
254
+ if (!isPlainObject(defaults)) return user as T | undefined;
255
+ if (!isPlainObject(user)) return defaults;
256
+ return { ...defaults, ...user };
257
+ }
258
+
259
+ /**
260
+ * Merge one rule group.
261
+ *
262
+ * Crucially this starts from the defaults for *every* rule in the group, so
263
+ * mentioning only `clean-code` in the file cannot drop the `solid` group.
264
+ * The generic preserves the concrete key set of the group being merged.
265
+ */
266
+ function mergeGroup<T extends Record<string, RuleConfig>>(
267
+ defaults: T,
268
+ user: Record<string, Partial<RuleConfig>> | undefined
269
+ ): T {
270
+ const result = { ...defaults };
271
+ if (!isPlainObject(user)) return result;
272
+
273
+ for (const ruleId of Object.keys(defaults)) {
274
+ const override = user[ruleId];
275
+ if (!isPlainObject(override)) continue;
276
+ // Safe: `ruleId` comes from Object.keys(defaults), so it is a key of T.
277
+ (result as Record<string, RuleConfig>)[ruleId] = mergeRule(
278
+ defaults[ruleId],
279
+ override as Partial<RuleConfig>,
280
+ ruleId
281
+ );
282
+ }
283
+ return result;
284
+ }
285
+
286
+ /** Merge one rule, keeping the default for any key whose override is mistyped. */
287
+ function mergeRule(
288
+ base: RuleConfig,
289
+ override: Partial<RuleConfig>,
290
+ ruleId: string
291
+ ): RuleConfig {
292
+ const merged: RuleConfig = { ...base };
293
+
294
+ if (typeof override.enabled === 'boolean') {
295
+ merged.enabled = override.enabled;
296
+ } else if (override.enabled !== undefined) {
297
+ warn(`rules.${ruleId}.enabled must be a boolean — keeping default`);
298
+ }
299
+
300
+ for (const key of NUMERIC_KEYS) {
301
+ const value = override[key];
302
+ if (value === undefined) continue;
303
+ if (typeof value === 'number' && Number.isFinite(value)) {
304
+ merged[key] = value;
305
+ } else {
306
+ warn(`rules.${ruleId}.${key} must be a finite number — keeping default`);
307
+ }
308
+ }
309
+
310
+ for (const key of ARRAY_KEYS) {
311
+ const value = override[key];
312
+ if (value === undefined) continue;
313
+ // Arrays REPLACE rather than concatenate, matching how .principlesrc is
314
+ // written today (an exclude list is the project's complete list).
315
+ if (Array.isArray(value)) {
316
+ merged[key] = [...value] as RuleConfig[typeof key];
317
+ } else {
318
+ warn(`rules.${ruleId}.${key} must be an array — keeping default`);
319
+ }
320
+ }
321
+
322
+ for (const key of Object.keys(ENUM_KEYS) as (keyof typeof ENUM_KEYS)[]) {
323
+ const allowed: readonly string[] = ENUM_KEYS[key];
324
+ const value = override[key];
325
+ if (value === undefined) continue;
326
+ if (typeof value === 'string' && allowed.includes(value)) {
327
+ merged[key] = value;
328
+ } else {
329
+ // Loud on purpose: silently keeping the default would let a project believe
330
+ // it raised a rule to blocking when it did not.
331
+ warn(`rules.${ruleId}.${key} must be one of ${allowed.join(', ')} — keeping default`);
332
+ }
117
333
  }
334
+
335
+ return merged;
336
+ }
337
+
338
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
339
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
340
+ }
341
+
342
+ function describeError(error: unknown): string {
343
+ return error instanceof Error ? error.message : String(error);
344
+ }
345
+
346
+ /** Emit a diagnostic without failing the run (Gate 4 must stay usable). */
347
+ function warn(message: string): void {
348
+ process.stderr.write(`⚠️ [principles] ${message}\n`);
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Active configuration
353
+ // ---------------------------------------------------------------------------
354
+ //
355
+ // Rules read their thresholds lazily through `getActiveConfig()` instead of
356
+ // snapshotting `getDefaultConfig()` at module load. That snapshot was #457: a
357
+ // project's `.principlesrc` was parsed but never consulted, so Gate 4 silently
358
+ // enforced the built-in defaults.
359
+ //
360
+ // CONCURRENCY: this is process-global mutable state. A single process must not
361
+ // evaluate two different projects' configs at the same time. Callers that need
362
+ // isolation should run them in separate processes. Tests MUST call
363
+ // `resetActiveConfig()` between cases to avoid leaking state -- and doing so is
364
+ // no longer only a convention: `setActiveConfig` reports a replacement of an
365
+ // already-installed config, so the leak fails visibly instead of silently.
366
+ // ---------------------------------------------------------------------------
367
+
368
+ let activeConfig: PrinciplesConfig = getDefaultConfig();
369
+
370
+ // Set by setActiveConfig, cleared by resetActiveConfig. The concurrency contract
371
+ // above was documentation only; this makes an undocumented second set visible
372
+ // instead of letting the last writer silently win (walkthrough FC-02).
373
+ let activeConfigInstalled = false;
374
+
375
+ /** The configuration rules currently enforce. */
376
+ export function getActiveConfig(): PrinciplesConfig {
377
+ return activeConfig;
378
+ }
379
+
380
+ /**
381
+ * The effective configuration of one rule, read from what is being enforced NOW.
382
+ *
383
+ * `Rule.threshold` and `Rule.severity` are deprecated snapshots taken when the
384
+ * rule module was constructed; a project's `.principlesrc` overrides them at
385
+ * run time (#457). Consumers that report or compare thresholds must read them
386
+ * here, or they will describe a number `check()` never compared against
387
+ * (walkthrough MAJ-03 / FC-05).
388
+ *
389
+ * Fails on an unknown id rather than returning defaults: answering "there is no
390
+ * such rule" with a plausible-looking threshold is the same silent-defaults
391
+ * defect this module exists to remove.
392
+ */
393
+ export function getEffectiveConfigFor(ruleId: string): RuleConfig {
394
+ const separator = ruleId.indexOf('.');
395
+ if (separator <= 0 || separator === ruleId.length - 1) {
396
+ throw new Error(`rule id must be in group.name form, got "${ruleId}"`);
397
+ }
398
+ const group = ruleId.slice(0, separator);
399
+ const name = ruleId.slice(separator + 1);
400
+ const groups = activeConfig.rules as Record<string, Record<string, RuleConfig> | undefined>;
401
+ const settings = groups[group]?.[name];
402
+ if (!settings) {
403
+ throw new Error(`no rule config for "${ruleId}" in the active configuration`);
404
+ }
405
+ return settings;
406
+ }
407
+
408
+ /** Install the configuration that rules should enforce. */
409
+ export function setActiveConfig(config: PrinciplesConfig): void {
410
+ if (activeConfigInstalled) {
411
+ warn('active config replaced without resetActiveConfig(); one process should analyse one project with one config');
412
+ }
413
+ activeConfig = config;
414
+ activeConfigInstalled = true;
415
+ }
416
+
417
+ /**
418
+ * Restore the built-in defaults.
419
+ *
420
+ * Exists for test isolation: without it, one test's `setActiveConfig` leaks into
421
+ * the next and produces order-dependent failures.
422
+ */
423
+ export function resetActiveConfig(): void {
424
+ activeConfig = getDefaultConfig();
425
+ activeConfigInstalled = false;
118
426
  }
@@ -0,0 +1,60 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ /**
6
+ * Decide whether the calling module is the process entry point.
7
+ *
8
+ * Why this exists: the previous check in `boy-scout.ts` was
9
+ * `process.argv[1]?.includes('boy-scout')`, which is wrong in both directions.
10
+ * It fires for any unrelated path that happens to contain the substring (e.g.
11
+ * `/tmp/boy-scout-notes/run.ts`), and it fails to fire when the real script is
12
+ * invoked through a path that does not repeat the filename -- which is exactly
13
+ * what happens under CI, where argv[1] is the test runner's path. The CLI then
14
+ * silently does nothing and prints nothing (#453).
15
+ *
16
+ * IMPORTANT -- pass `metaUrl` explicitly. `import.meta.url` is evaluated where
17
+ * it is *written*, so a default of `import.meta.url` inside this shared module
18
+ * captures THIS file's URL, not the caller's, and every guard would return false.
19
+ * Callers must use `isDirectExecution(process.argv[1], import.meta.url)`.
20
+ *
21
+ * Both sides are realpath'd before comparison. Node resolves ESM module URLs
22
+ * through symlinks, while `argv[1]` keeps whatever path the caller typed -- so a
23
+ * bin shim, `npm link`, or a symlinked checkout compares unequal without this and
24
+ * the CLI silently no-ops, which is the same failure class as #453.
25
+ */
26
+ export function isDirectExecution(
27
+ argv1: string | undefined,
28
+ metaUrl: string | undefined,
29
+ ): boolean {
30
+ // CJS entry points are already unambiguous, and `require.main === module` is
31
+ // exact. This tests the CALLER's module only when the caller's own file runs
32
+ // this branch; under ESM the whole expression is inert because `typeof require`
33
+ // is 'undefined' (that is why it is guarded by `typeof`, not `require` -- a bare
34
+ // `require.main` would throw ReferenceError in ESM).
35
+ if (typeof require !== 'undefined' && require.main === module) {
36
+ return true;
37
+ }
38
+ if (!metaUrl || !argv1) {
39
+ return false;
40
+ }
41
+ try {
42
+ return canonical(pathToFileURL(resolve(argv1)).href) === canonical(metaUrl);
43
+ } catch {
44
+ // An unparsable argv[1] is not an entry point; never throw from a guard.
45
+ return false;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolve a file URL to its real path so symlinked and non-symlinked spellings of
51
+ * the same file compare equal. Falls back to the input when the path does not
52
+ * exist (a guard must not throw) -- an unresolvable URL simply will not match.
53
+ */
54
+ function canonical(fileUrl: string): string {
55
+ try {
56
+ return pathToFileURL(realpathSync(new URL(fileUrl))).href;
57
+ } catch {
58
+ return fileUrl;
59
+ }
60
+ }
@@ -1,13 +1,16 @@
1
1
  import { analyze, getAdapterForFile } from './analyzer';
2
2
  import { formatConsole, formatJSON, formatSARIF } from './reporter';
3
- import { loadConfig } from './config';
3
+ import { loadConfig, setActiveConfig } from './config';
4
4
  import { getAllPrincipleRules } from './rules';
5
+ import { isDirectExecution } from './direct-execution.js';
5
6
 
6
7
  interface CLIOptions {
7
8
  files: string[];
8
9
  format: 'console' | 'json' | 'sarif';
9
10
  changedOnly: boolean;
10
11
  showScore: boolean;
12
+ /** Explicit `.principlesrc` path; falls back to git-toplevel/cwd lookup. */
13
+ configPath?: string;
11
14
  }
12
15
 
13
16
  const VALID_FORMATS: readonly string[] = ['json', 'console', 'sarif'] as const;
@@ -36,6 +39,12 @@ export function parseArgs(args: string[]): CLIOptions {
36
39
  case '--show-score':
37
40
  options.showScore = true;
38
41
  break;
42
+ // Previously absent, so `--config .principlesrc` was silently filed into
43
+ // `files` and the path was analysed as if it were source (#457).
44
+ case '--config':
45
+ const cfg = args[++i];
46
+ if (cfg) options.configPath = cfg;
47
+ break;
39
48
  default:
40
49
  if (!args[i].startsWith('--')) options.files.push(args[i]);
41
50
  }
@@ -54,11 +63,19 @@ export async function main(args: string[]): Promise<number> {
54
63
  const options = parseArgs(args);
55
64
 
56
65
  if (options.files.length === 0) {
57
- console.error('Usage: principles-checker --files <file1> <file2> ... [--format console|json|sarif] [--changed-only]');
58
- return 1;
66
+ console.error('Usage: principles-checker --files <file1> <file2> ... [--format console|json|sarif] [--changed-only] [--config <path>]');
67
+ // 2, not 1. Under this module's contract exit 1 asserts "I examined files and
68
+ // found ERROR-severity violations". A gate that sees 1 with an empty report
69
+ // reads it as a clean pass, so returning 1 here would let a miswired hook
70
+ // announce PASSED having checked nothing at all.
71
+ return 2;
59
72
  }
60
73
 
61
- await loadConfig();
74
+ // Install the loaded config so rules read the PROJECT's thresholds. The
75
+ // previous `await loadConfig();` discarded the result, so `.principlesrc` was
76
+ // parsed and thrown away while the built-in defaults were enforced (#457).
77
+ setActiveConfig(await loadConfig(options.configPath));
78
+
62
79
  const rules = getAllRules();
63
80
  const result = await analyze(options.files, rules, getAdapterForFile);
64
81
 
@@ -69,24 +86,23 @@ export async function main(args: string[]): Promise<number> {
69
86
  };
70
87
  console.log(formatters[options.format](result));
71
88
 
72
- return result.summary.totalViolations > 0 ? 1 : 0;
89
+ // Exit 1 only for ERROR-severity violations. Returning 1 for any violation
90
+ // made info/warning noise (e.g. magic-numbers `info`) look like a tool
91
+ // failure: Gate 4 treats a non-zero exit as "checker execution failed" and
92
+ // downgrades itself to SKIPPED, silently disabling the gate.
93
+ return result.summary.errorCount > 0 ? 1 : 0;
73
94
  }
74
95
 
75
96
  // Support both CJS (require.main === module) and ESM (import.meta.url) runtimes.
76
97
  // npx tsx loads files via import() making require.main unreliable.
77
- function isDirectExecution(): boolean {
78
- if (typeof require !== 'undefined' && require.main === module) {
79
- return true;
80
- }
81
- if (typeof import.meta !== 'undefined' && import.meta.url && process.argv[1]) {
82
- const fileUrl = `file://${process.argv[1]}`;
83
- return import.meta.url === fileUrl || import.meta.url.endsWith(`/${process.argv[1]}`);
84
- }
85
- return false;
86
- }
98
+ //
99
+ // The implementation lives in ./direct-execution.ts so the identical guard can be
100
+ // shared with boy-scout.ts without either module exceeding the 10-export limit.
101
+ // Re-exported here because it is part of this module's public contract (#444).
102
+ export { isDirectExecution } from './direct-execution.js';
87
103
 
88
104
  const args = process.argv.slice(2);
89
- if (isDirectExecution()) {
105
+ if (isDirectExecution(process.argv[1], import.meta.url)) {
90
106
  main(args)
91
107
  .then(exitCode => {
92
108
  if (exitCode !== 0) {
@@ -95,6 +111,13 @@ if (isDirectExecution()) {
95
111
  })
96
112
  .catch(err => {
97
113
  console.error('Analysis failed:', err.message);
98
- process.exit(1);
114
+ // Exit 2, not 1. Gate 4 decides by testing the exit status, and exit 1 now
115
+ // means "the checker ran and found ERROR-severity violations". Reusing 1 for
116
+ // a crash would make a genuine finding indistinguishable from a broken tool,
117
+ // and the gate's `else` branch SKIPs -- releasing exactly the most serious
118
+ // violations. 2 is this repo's existing convention for a runtime error:
119
+ // src/gates/gate-8.ts and gate-9.ts both SKIP on exit >= 2 and treat 1 as a
120
+ // real finding.
121
+ process.exit(2);
99
122
  });
100
123
  }
@@ -1,17 +1,23 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
 
3
- vi.mock('../../../config', () => ({
4
- getDefaultConfig: () => ({
5
- rules: {
6
- 'clean-code': {
7
- 'many-exports': {
8
- enabled: true,
9
- // threshold intentionally omitted -> exercises ?? 10 fallback branch
10
- severity: 'warning',
11
- },
3
+ // The rule now reads its settings from `getActiveConfig()` at check time
4
+ // (Issue #457: a module-load snapshot ignored `.principlesrc`), so the mock must
5
+ // expose that accessor. The same fixture is returned by both entry points.
6
+ const mockConfig = {
7
+ rules: {
8
+ 'clean-code': {
9
+ 'many-exports': {
10
+ enabled: true,
11
+ // threshold intentionally omitted -> exercises ?? 10 fallback branch
12
+ severity: 'warning',
12
13
  },
13
14
  },
14
- }),
15
+ },
16
+ };
17
+
18
+ vi.mock('../../../config', () => ({
19
+ getDefaultConfig: () => mockConfig,
20
+ getActiveConfig: () => mockConfig,
15
21
  }));
16
22
 
17
23
  import { manyExportsRule } from '../../clean-code/many-exports';
@@ -1,27 +1,27 @@
1
1
  import { Rule, Violation, Severity } from '../../types';
2
- import { getDefaultConfig } from '../../config';
3
-
4
- const config = getDefaultConfig();
2
+ import { getActiveConfig } from '../../config';
5
3
 
6
4
  export const codeDuplicationRule: Rule = {
7
5
  id: 'clean-code.code-duplication',
8
6
  name: 'Code Duplication Rule',
9
- threshold: config.rules['clean-code']['code-duplication'].threshold ?? 15,
10
- severity: config.rules['clean-code']['code-duplication'].severity as Severity,
7
+ threshold: 15,
8
+ severity: 'warning',
11
9
  check: (file: string, adapter: unknown): Violation[] => {
12
10
  const violations: Violation[] = [];
11
+ // Read the ACTIVE config per invocation; a module-load snapshot was #457.
12
+ const settings = getActiveConfig().rules['clean-code']['code-duplication'];
13
13
 
14
14
  try {
15
15
  const typedAdapter = adapter as { duplicationPercentage?: number };
16
16
  const duplicationPercentage = typedAdapter.duplicationPercentage;
17
17
 
18
- if (duplicationPercentage && duplicationPercentage > (config.rules['clean-code']['code-duplication'].threshold as number)) {
18
+ if (duplicationPercentage && duplicationPercentage > (settings.threshold ?? 0)) {
19
19
  violations.push({
20
20
  file,
21
21
  line: 1,
22
22
  ruleId: 'clean-code.code-duplication',
23
- message: `Code duplication detected: ${duplicationPercentage}% (threshold: ${config.rules['clean-code']['code-duplication'].threshold}%). Consider refactoring duplicated code.`,
24
- severity: config.rules['clean-code']['code-duplication'].severity as Severity
23
+ message: `Code duplication detected: ${duplicationPercentage}% (threshold: ${settings.threshold}%). Consider refactoring duplicated code.`,
24
+ severity: (settings.severity as Severity) ?? 'warning'
25
25
  });
26
26
  }
27
27
  } catch { }