claude-dev-env 8.4.2 → 8.5.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.
@@ -7,10 +7,63 @@ import logging
7
7
  import os
8
8
  import stat
9
9
  import subprocess
10
+ import sys
10
11
  from pathlib import Path
11
12
 
13
+ from typing import NamedTuple
14
+
12
15
  from subprocess_window_access import hidden_window_creation_flags
13
16
 
17
+ _hooks_directory = str(Path(__file__).resolve().parents[1] / "hooks")
18
+ if _hooks_directory not in sys.path:
19
+ sys.path.insert(0, _hooks_directory)
20
+
21
+ from dev_env_scripts_constants.followup_constants import (
22
+ FILENAME_RULE_ID,
23
+ GATE_FAILED_EXIT_CODE,
24
+ GATE_PASSED_EXIT_CODE,
25
+ GIT_MODE_RULE_ID,
26
+ IMPORT_TEXT_RULE_ID,
27
+ MISSING_AGENTS_RULE_ID,
28
+ RECORDED_SMELL_TEMPLATE,
29
+ REGULAR_FILE_RULE_ID,
30
+ SEVERITY_BY_RULE_ID,
31
+ )
32
+ from followup_ledger import FollowupFinding, record_followup_finding
33
+ from hooks_constants.followup_ledger_constants import SEVERITY_BREAKING
34
+
35
+ class InstructionFinding(NamedTuple):
36
+ """One instruction-pair finding and the severity that decides the gate.
37
+
38
+ Attributes:
39
+ rule_id: Stable identifier of the check that raised the finding.
40
+ file_path: Repository-relative path the finding names.
41
+ message: The text a reader acts on.
42
+ severity: The severity SEVERITY_BY_RULE_ID declares for rule_id.
43
+ """
44
+
45
+ rule_id: str
46
+ file_path: str
47
+ message: str
48
+ severity: str
49
+
50
+
51
+ def _finding(rule_id: str, relative_path: Path, message: str) -> InstructionFinding:
52
+ """Build one finding carrying the severity its rule identifier declares.
53
+
54
+ Args:
55
+ rule_id: The check's stable identifier.
56
+ relative_path: Repository-relative path the finding names.
57
+ message: The text a reader acts on.
58
+
59
+ Returns:
60
+ The finding with its declared severity attached.
61
+ """
62
+ return InstructionFinding(
63
+ rule_id, relative_path.as_posix(), message, SEVERITY_BY_RULE_ID[rule_id]
64
+ )
65
+
66
+
14
67
  git_directory_name = ".git"
15
68
  git_listing_commands = (
16
69
  (("git", "ls-files", "--stage", "-z"), True),
@@ -97,65 +150,248 @@ def _is_regular_file(each_path: Path) -> bool:
97
150
  return False
98
151
 
99
152
 
100
- def validate_repository(repository_root: Path) -> list[str]:
101
- """Validate instruction filenames, modes, and exact Claude imports.
153
+ def _all_file_check_rows(
154
+ instruction_path: Path, all_tracked_modes: set[str]
155
+ ) -> tuple[tuple[str, bool, str], ...]:
156
+ """Pair each per-file rule with whether it failed and the text it reports.
102
157
 
103
158
  Args:
104
- repository_root: Repository directory containing the Git metadata.
159
+ instruction_path: The AGENTS.md or CLAUDE.md to check.
160
+ all_tracked_modes: Committed Git modes this path carries.
105
161
 
106
162
  Returns:
107
- Human-readable validation errors, with an empty list for a valid tree.
163
+ One row per per-file rule, each holding its rule identifier, whether
164
+ the check failed, and its message prefix.
108
165
  """
109
- repository_root = repository_root.resolve()
110
- all_errors: list[str] = []
111
- all_git_paths, modes_by_path = _read_git_paths_and_modes(repository_root)
112
- all_agents_paths = _discover_named_files(
113
- repository_root, all_git_paths, "AGENTS.md"
166
+ return (
167
+ (
168
+ FILENAME_RULE_ID,
169
+ instruction_path.name not in canonical_instruction_names,
170
+ "Use the canonical filename",
171
+ ),
172
+ (
173
+ REGULAR_FILE_RULE_ID,
174
+ not _is_regular_file(instruction_path),
175
+ "Use a regular file",
176
+ ),
177
+ (
178
+ GIT_MODE_RULE_ID,
179
+ all_tracked_modes != {regular_git_file_mode},
180
+ "Commit the instruction file with Git mode 100644",
181
+ ),
182
+ )
183
+
184
+
185
+ def _instruction_file_findings(
186
+ instruction_path: Path,
187
+ repository_root: Path,
188
+ all_tracked_modes: set[str],
189
+ ) -> list[InstructionFinding]:
190
+ """Check one instruction file's name, kind, and committed Git mode.
191
+
192
+ Args:
193
+ instruction_path: The AGENTS.md or CLAUDE.md to check.
194
+ repository_root: The resolved repository root.
195
+ all_tracked_modes: Committed Git modes this path carries.
196
+
197
+ Returns:
198
+ Every finding these three checks raise for this file.
199
+ """
200
+ relative_path = instruction_path.relative_to(repository_root)
201
+ return [
202
+ _finding(each_rule_id, relative_path, f"{each_text}: {relative_path}")
203
+ for each_rule_id, each_check_failed, each_text in _all_file_check_rows(
204
+ instruction_path, all_tracked_modes
205
+ )
206
+ if each_check_failed
207
+ ]
208
+
209
+
210
+ def _import_text_finding(
211
+ claude_path: Path, agents_path: Path, repository_root: Path
212
+ ) -> InstructionFinding | None:
213
+ """Compare one CLAUDE.md against the import text its AGENTS.md requires.
214
+
215
+ Args:
216
+ claude_path: The CLAUDE.md to read.
217
+ agents_path: The AGENTS.md it must import.
218
+ repository_root: The resolved repository root.
219
+
220
+ Returns:
221
+ The finding, or None when the import text is exact.
222
+ """
223
+ if not _is_regular_file(claude_path):
224
+ return None
225
+ if claude_path.read_bytes() == _expected_import_text(claude_path, agents_path):
226
+ return None
227
+ relative_claude_path = claude_path.relative_to(repository_root)
228
+ relative_import_path = _relative_import_path(claude_path, agents_path)
229
+ relative_agents_path = agents_path.relative_to(repository_root)
230
+ return _finding(
231
+ IMPORT_TEXT_RULE_ID,
232
+ relative_claude_path,
233
+ f"Make {relative_claude_path} exactly import "
234
+ f"@{relative_import_path} for {relative_agents_path}",
114
235
  )
115
- all_claude_paths = _discover_named_files(
116
- repository_root, all_git_paths, "CLAUDE.md"
236
+
237
+
238
+ def _import_finding(
239
+ claude_path: Path,
240
+ repository_root: Path,
241
+ agents_by_directory: dict[Path, Path],
242
+ ) -> InstructionFinding | None:
243
+ """Check one CLAUDE.md against the AGENTS.md it must import.
244
+
245
+ Args:
246
+ claude_path: The CLAUDE.md to check.
247
+ repository_root: The resolved repository root.
248
+ agents_by_directory: Discovered AGENTS.md paths keyed by directory.
249
+
250
+ Returns:
251
+ The finding this file raises, or None when its import text is exact.
252
+ """
253
+ agents_path = _find_nearest_agents_path(
254
+ claude_path, repository_root, agents_by_directory
117
255
  )
118
- agents_by_directory = {
256
+ if agents_path is None:
257
+ relative_claude_path = claude_path.relative_to(repository_root)
258
+ return _finding(
259
+ MISSING_AGENTS_RULE_ID,
260
+ relative_claude_path,
261
+ f"Add the nearest governing AGENTS.md: {relative_claude_path}",
262
+ )
263
+ return _import_text_finding(claude_path, agents_path, repository_root)
264
+
265
+
266
+ def _agents_paths_by_directory(all_agents_paths: list[Path]) -> dict[Path, Path]:
267
+ """Key each discovered AGENTS.md by the directory that holds it.
268
+
269
+ Args:
270
+ all_agents_paths: Every AGENTS.md discovered in the tree.
271
+
272
+ Returns:
273
+ The AGENTS.md path for each directory that holds one.
274
+ """
275
+ return {
119
276
  each_path.parent: each_path
120
277
  for each_path in all_agents_paths
121
278
  if each_path.name == "AGENTS.md"
122
279
  }
123
280
 
124
- for each_instruction_path in (*all_agents_paths, *all_claude_paths):
125
- relative_path = each_instruction_path.relative_to(repository_root)
126
- tracked_modes = modes_by_path.get(each_instruction_path, set())
127
- if each_instruction_path.name not in canonical_instruction_names:
128
- all_errors.append(f"Use the canonical filename: {relative_path}")
129
- if not _is_regular_file(each_instruction_path):
130
- all_errors.append(f"Use a regular file: {relative_path}")
131
- if tracked_modes != {regular_git_file_mode}:
132
- all_errors.append(
133
- f"Commit the instruction file with Git mode 100644: {relative_path}"
281
+
282
+ def _all_file_findings(
283
+ all_instruction_paths: tuple[Path, ...],
284
+ repository_root: Path,
285
+ modes_by_path: dict[Path, set[str]],
286
+ ) -> list[InstructionFinding]:
287
+ """Run the per-file checks over every discovered instruction file.
288
+
289
+ Args:
290
+ all_instruction_paths: Every AGENTS.md and CLAUDE.md discovered.
291
+ repository_root: The resolved repository root.
292
+ modes_by_path: Committed Git modes keyed by absolute path.
293
+
294
+ Returns:
295
+ Every per-file finding, in discovery order.
296
+ """
297
+ all_findings: list[InstructionFinding] = []
298
+ for each_instruction_path in all_instruction_paths:
299
+ all_findings.extend(
300
+ _instruction_file_findings(
301
+ each_instruction_path,
302
+ repository_root,
303
+ modes_by_path.get(each_instruction_path, set()),
134
304
  )
305
+ )
306
+ return all_findings
307
+
308
+
309
+ def all_instruction_findings(repository_root: Path) -> tuple[InstructionFinding, ...]:
310
+ """Collect every instruction-pair finding with the severity that decides the gate.
135
311
 
312
+ Args:
313
+ repository_root: Repository directory containing the Git metadata.
314
+
315
+ Returns:
316
+ Every finding in discovery order, each carrying its rule identifier,
317
+ the repository-relative path it names, its text, and its severity.
318
+ """
319
+ repository_root = repository_root.resolve()
320
+ all_git_paths, modes_by_path = _read_git_paths_and_modes(repository_root)
321
+ all_agents_paths = _discover_named_files(repository_root, all_git_paths, "AGENTS.md")
322
+ all_claude_paths = _discover_named_files(repository_root, all_git_paths, "CLAUDE.md")
323
+ agents_by_directory = _agents_paths_by_directory(all_agents_paths)
324
+
325
+ all_findings = _all_file_findings(
326
+ (*all_agents_paths, *all_claude_paths), repository_root, modes_by_path
327
+ )
136
328
  for each_claude_path in all_claude_paths:
137
- relative_claude_path = each_claude_path.relative_to(repository_root)
138
- each_agents_path = _find_nearest_agents_path(
329
+ each_finding = _import_finding(
139
330
  each_claude_path, repository_root, agents_by_directory
140
331
  )
141
- if each_agents_path is None:
142
- all_errors.append(f"Add the nearest governing AGENTS.md: {relative_claude_path}")
143
- continue
144
- if not _is_regular_file(each_claude_path):
332
+ if each_finding is not None:
333
+ all_findings.append(each_finding)
334
+ return tuple(all_findings)
335
+
336
+
337
+ def validate_repository(repository_root: Path) -> list[str]:
338
+ """Validate instruction filenames, modes, and exact Claude imports.
339
+
340
+ Args:
341
+ repository_root: Repository directory containing the Git metadata.
342
+
343
+ Returns:
344
+ Human-readable validation errors, with an empty list for a valid tree.
345
+ """
346
+ return [
347
+ each_finding.message
348
+ for each_finding in all_instruction_findings(repository_root)
349
+ ]
350
+
351
+
352
+ def _record_smell(repository_root: Path, finding: InstructionFinding) -> None:
353
+ """Record one smell finding in the repository's follow-up ledger.
354
+
355
+ Args:
356
+ repository_root: The repository whose ledger receives the finding.
357
+ finding: The non-breaking finding to record.
358
+ """
359
+ record_followup_finding(
360
+ repository_root,
361
+ FollowupFinding(finding.rule_id, finding.file_path, finding.message),
362
+ )
363
+ logger.warning(RECORDED_SMELL_TEMPLATE, finding.message)
364
+
365
+
366
+ def run_gate(repository_root: Path) -> int:
367
+ """Report breaking findings, record smells, and return the gate exit code.
368
+
369
+ A breaking finding means the instruction files do not load as written, so
370
+ the gate fails. A smell is recorded in the repository's follow-up ledger
371
+ and leaves the gate passing, so a later pull request resolves it.
372
+
373
+ Args:
374
+ repository_root: Repository directory containing the Git metadata.
375
+
376
+ Returns:
377
+ GATE_FAILED_EXIT_CODE when any breaking finding is present, otherwise
378
+ GATE_PASSED_EXIT_CODE.
379
+ """
380
+ resolved_root = repository_root.resolve()
381
+ all_breaking_messages: list[str] = []
382
+
383
+ for each_finding in all_instruction_findings(resolved_root):
384
+ if each_finding.severity == SEVERITY_BREAKING:
385
+ all_breaking_messages.append(each_finding.message)
145
386
  continue
146
- expected_bytes = _expected_import_text(each_claude_path, each_agents_path)
147
- actual_bytes = each_claude_path.read_bytes()
148
- if actual_bytes != expected_bytes:
149
- relative_import_path = _relative_import_path(
150
- each_claude_path, each_agents_path
151
- )
152
- relative_agents_path = each_agents_path.relative_to(repository_root)
153
- all_errors.append(
154
- f"Make {relative_claude_path} exactly import "
155
- f"@{relative_import_path} for {relative_agents_path}"
156
- )
387
+ _record_smell(resolved_root, each_finding)
388
+
389
+ for each_message in all_breaking_messages:
390
+ logger.error("%s", each_message)
157
391
 
158
- return all_errors
392
+ if all_breaking_messages:
393
+ return GATE_FAILED_EXIT_CODE
394
+ return GATE_PASSED_EXIT_CODE
159
395
 
160
396
 
161
397
  def _parse_arguments() -> argparse.Namespace:
@@ -173,13 +409,7 @@ def _parse_arguments() -> argparse.Namespace:
173
409
 
174
410
  def _main() -> int:
175
411
  parsed_arguments = _parse_arguments()
176
- repository_root = parsed_arguments.repository_root.resolve()
177
- all_errors = validate_repository(repository_root)
178
- if all_errors:
179
- for each_error in all_errors:
180
- logger.error("%s", each_error)
181
- return 1
182
- return 0
412
+ return run_gate(parsed_arguments.repository_root)
183
413
 
184
414
 
185
415
  if __name__ == "__main__":
@@ -32,8 +32,15 @@ $scriptsRoot = Split-Path -Parent (Split-Path -Parent $entrypoint)
32
32
  $exitCode = 1
33
33
  Push-Location $scriptsRoot
34
34
  try {
35
- & bun $entrypoint @commandArguments
36
- $exitCode = $LASTEXITCODE
35
+ & bun -e 'import { ensureDependenciesInstalled } from "./bootstrap.ts"; ensureDependenciesInstalled();'
36
+ $bootstrapExitCode = $LASTEXITCODE
37
+ if ($bootstrapExitCode -ne 0) {
38
+ Write-Error "The pstack dependencies could not be installed"
39
+ $exitCode = $bootstrapExitCode
40
+ } else {
41
+ & bun $entrypoint @commandArguments
42
+ $exitCode = $LASTEXITCODE
43
+ }
37
44
  }
38
45
  finally {
39
46
  Pop-Location
@@ -1,59 +0,0 @@
1
- import { writeFileSync } from "node:fs";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
-
5
- import { auditHooks } from "./audit-hooks.mjs";
6
-
7
- function parseArguments(argv) {
8
- const options = {
9
- catalogPath: null,
10
- format: "text",
11
- homeDirectory: null,
12
- includeInstalled: false,
13
- outputPath: null,
14
- requireNonblocking: false,
15
- repositoryRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."),
16
- };
17
- for (let offset = 0; offset < argv.length; offset += 1) {
18
- const argument = argv[offset];
19
- if (argument === "--installed") options.includeInstalled = true;
20
- else if (argument === "--catalog") options.catalogPath = argv[++offset];
21
- else if (argument === "--format") options.format = argv[++offset];
22
- else if (argument === "--home") options.homeDirectory = argv[++offset];
23
- else if (argument === "--output") options.outputPath = argv[++offset];
24
- else if (argument === "--require-nonblocking") options.requireNonblocking = true;
25
- else if (argument === "--repository-root") options.repositoryRoot = argv[++offset];
26
- else throw new Error(`unknown argument ${argument}`);
27
- }
28
- return options;
29
- }
30
-
31
- function renderText(report) {
32
- const summary = report.summary;
33
- return [
34
- `canonical direct: ${summary.directCount}`,
35
- `dispatchers: ${summary.dispatcherCount}`,
36
- `hosted entries: ${summary.effectiveCount}`,
37
- `logical associations: ${summary.logicalAssociationCount}`,
38
- `unclassified targets: ${summary.unclassifiedCount}`,
39
- `installed Claude direct: ${summary.claudeDirectCount}`,
40
- `installed Codex direct: ${summary.codexDirectCount}`,
41
- `active native Git hooks: ${summary.gitHookCount}`,
42
- `known native Git hooks: ${summary.knownGitHookCount}`,
43
- `registration eligibility: ${report.registrationEligibility.status}`,
44
- "runtime proof: separate verification required",
45
- `findings: ${summary.findingCount}`,
46
- ].join("\n");
47
- }
48
-
49
- function main() {
50
- const options = parseArguments(process.argv.slice(2));
51
- const report = auditHooks(options);
52
- const rendered =
53
- options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`;
54
- if (options.outputPath) writeFileSync(options.outputPath, rendered, "utf8");
55
- else process.stdout.write(rendered);
56
- process.exitCode = report.findings.some(({ severity }) => severity === "error") ? 1 : 0;
57
- }
58
-
59
- main();