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.
- package/commands/fix-followups.md +66 -0
- package/hooks/followup_ledger.py +143 -0
- package/hooks/hooks_constants/followup_ledger_constants.py +27 -0
- package/hooks/test_followup_ledger.py +83 -0
- package/package.json +1 -1
- package/rules/flag-non-breaking-findings.md +72 -0
- package/scripts/dev_env_scripts_constants/followup_constants.py +72 -0
- package/scripts/followup_cli.py +273 -0
- package/scripts/test_followup_cli.py +156 -0
- package/scripts/test_validate_instruction_pairs.py +144 -1
- package/scripts/validate_instruction_pairs.py +277 -47
- package/scripts/windows/pstack/pstack-command-shim.ps1 +9 -2
- package/scripts/audit-hooks-cli.mjs +0 -59
- package/scripts/audit-hooks.mjs +0 -452
- package/scripts/tests/test-audit-hooks-cli.mjs +0 -150
- package/scripts/tests/test-audit-hooks.mjs +0 -497
|
@@ -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
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
One row per per-file rule, each holding its rule identifier, whether
|
|
164
|
+
the check failed, and its message prefix.
|
|
108
165
|
"""
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
$
|
|
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();
|