claude-dev-env 8.11.0 → 8.11.1

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.
@@ -5,22 +5,33 @@
5
5
  title "docs: ..." -> pass, nothing to prove
6
6
  title "fix: ..." changing only docs or .github -> pass, nothing to prove
7
7
  title "fix: ..." with no changed Python test -> fail
8
+ title "fix: ..." with no changed Python or Node test -> fail
8
9
  changed test passes on head, fails on base -> pass
9
10
  changed test passes on both -> fail
11
+ any changed test fails on head -> fail
10
12
 
11
- The base run reuses the regression gate's detached worktree: the changed test
13
+ Python tests reuse the regression gate's detached worktree: the changed test
12
14
  files are copied onto the base checkout, which runs them with import isolation.
13
- Python tests only.
15
+ Node tests (``*.test.mjs``, ``*.test.js``, ``*.test.cjs``) run through
16
+ ``node --test`` on the head, then on a detached base checkout that holds a copy
17
+ of the changed test files. The fix is proven when at least one language's
18
+ changed tests fail on the base.
14
19
  """
15
20
 
16
21
  import argparse
22
+ import shutil
23
+ import subprocess
17
24
  import sys
25
+ import tempfile
26
+ from enum import Enum
18
27
  from pathlib import Path
19
28
 
20
29
  from code_rules_gate_parts import staged_test_regression, staged_test_running
21
30
  from code_rules_gate_parts.git_file_sets import paths_from_git_diff
22
31
  from code_rules_gate_parts.wrapper_plumb_check import is_test_path
23
32
  from pr_loop_shared_constants.fix_pr_test_proof_constants import (
33
+ ALL_NODE_TEST_COMMAND,
34
+ ALL_NODE_TEST_SUFFIXES,
24
35
  ALL_PRODUCTION_CODE_SUFFIXES,
25
36
  CI_CONFIG_DIRECTORY_NAME,
26
37
  FAILED_EXIT_CODE,
@@ -29,6 +40,8 @@ from pr_loop_shared_constants.fix_pr_test_proof_constants import (
29
40
  NO_BASE_FAILURE_MESSAGE,
30
41
  NO_CHANGED_TEST_MESSAGE,
31
42
  NO_PRODUCTION_CHANGE_MESSAGE,
43
+ NODE_BASE_WORKTREE_DIRECTORY_NAME,
44
+ NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX,
32
45
  NOT_A_FIX_MESSAGE,
33
46
  PASSED_EXIT_CODE,
34
47
  PROVEN_MESSAGE,
@@ -36,6 +49,15 @@ from pr_loop_shared_constants.fix_pr_test_proof_constants import (
36
49
  )
37
50
 
38
51
 
52
+ class ProofVerdict(Enum):
53
+ """The result of running one language's changed tests on the head and the base."""
54
+
55
+ HEAD_FAILED = "head_failed"
56
+ BASE_UNAVAILABLE = "base_unavailable"
57
+ PASSES_ON_BASE = "passes_on_base"
58
+ PROVEN = "proven"
59
+
60
+
39
61
  def _parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
40
62
  parser = argparse.ArgumentParser(prog="fix_pr_test_proof")
41
63
  parser.add_argument("--title", required=True)
@@ -72,9 +94,17 @@ def _changed_python_tests(all_changed_paths: list[Path]) -> list[Path]:
72
94
  ]
73
95
 
74
96
 
75
- def _proof_exit_code(
97
+ def _changed_node_tests(all_changed_paths: list[Path]) -> list[Path]:
98
+ return [
99
+ each_path
100
+ for each_path in all_changed_paths
101
+ if each_path.name.endswith(ALL_NODE_TEST_SUFFIXES) and each_path.is_file()
102
+ ]
103
+
104
+
105
+ def _python_proof_verdict(
76
106
  all_changed_tests: list[Path], repository_root: Path, base_revision: str
77
- ) -> int:
107
+ ) -> ProofVerdict:
78
108
  all_targets = staged_test_running._pytest_target_paths(all_changed_tests)
79
109
  all_tests_by_root = staged_test_running._group_staged_tests_by_root(
80
110
  all_targets, repository_root
@@ -87,18 +117,78 @@ def _proof_exit_code(
87
117
  for each_group_root, each_outcome in sorted(head_outcomes.items()):
88
118
  if each_outcome.exit_code != 0:
89
119
  sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=each_group_root) + "\n")
90
- return FAILED_EXIT_CODE
120
+ return ProofVerdict.HEAD_FAILED
91
121
  if base_outcomes is None:
92
122
  sys.stderr.write(WORKTREE_FAILED_MESSAGE.format(revision=base_revision) + "\n")
93
- return FAILED_EXIT_CODE
123
+ return ProofVerdict.BASE_UNAVAILABLE
94
124
  base_failure_count = sum(
95
125
  len(each_outcome.failing_identities) for each_outcome in base_outcomes.values()
96
126
  )
97
127
  if base_failure_count == 0:
98
- sys.stderr.write(NO_BASE_FAILURE_MESSAGE.format(revision=base_revision) + "\n")
99
- return FAILED_EXIT_CODE
128
+ return ProofVerdict.PASSES_ON_BASE
100
129
  sys.stdout.write(PROVEN_MESSAGE.format(count=base_failure_count) + "\n")
101
- return PASSED_EXIT_CODE
130
+ return ProofVerdict.PROVEN
131
+
132
+
133
+ def _node_tests_pass(working_directory: Path, all_relative_tests: list[Path]) -> bool:
134
+ node_run = subprocess.run(
135
+ [*ALL_NODE_TEST_COMMAND, *(each_path.as_posix() for each_path in all_relative_tests)],
136
+ cwd=working_directory,
137
+ capture_output=True,
138
+ text=True,
139
+ check=False,
140
+ )
141
+ return node_run.returncode == 0
142
+
143
+
144
+ def _copy_tests_onto(
145
+ base_worktree: Path, repository_root: Path, all_relative_tests: list[Path]
146
+ ) -> None:
147
+ for each_relative_test in all_relative_tests:
148
+ base_test_path = base_worktree / each_relative_test
149
+ base_test_path.parent.mkdir(parents=True, exist_ok=True)
150
+ shutil.copy2(repository_root / each_relative_test, base_test_path)
151
+
152
+
153
+ def _node_proof_verdict(
154
+ all_changed_tests: list[Path], repository_root: Path, base_revision: str
155
+ ) -> ProofVerdict:
156
+ all_relative_tests = [each_path.relative_to(repository_root) for each_path in all_changed_tests]
157
+ if not _node_tests_pass(repository_root, all_relative_tests):
158
+ sys.stderr.write(HEAD_FAILURE_MESSAGE.format(group_root=repository_root) + "\n")
159
+ return ProofVerdict.HEAD_FAILED
160
+ with tempfile.TemporaryDirectory(
161
+ prefix=NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX, ignore_cleanup_errors=True
162
+ ) as base_parent_text:
163
+ base_worktree = Path(base_parent_text) / NODE_BASE_WORKTREE_DIRECTORY_NAME
164
+ if not staged_test_regression._add_baseline_worktree(
165
+ repository_root, base_worktree, base_revision
166
+ ):
167
+ sys.stderr.write(WORKTREE_FAILED_MESSAGE.format(revision=base_revision) + "\n")
168
+ return ProofVerdict.BASE_UNAVAILABLE
169
+ try:
170
+ _copy_tests_onto(base_worktree, repository_root, all_relative_tests)
171
+ passes_on_base = _node_tests_pass(base_worktree, all_relative_tests)
172
+ finally:
173
+ staged_test_regression._remove_baseline_worktree(repository_root, base_worktree)
174
+ if passes_on_base:
175
+ return ProofVerdict.PASSES_ON_BASE
176
+ sys.stdout.write(PROVEN_MESSAGE.format(count=len(all_relative_tests)) + "\n")
177
+ return ProofVerdict.PROVEN
178
+
179
+
180
+ def _proof_exit_code(
181
+ all_verdicts: list[ProofVerdict], base_revision: str
182
+ ) -> int:
183
+ if any(
184
+ each_verdict in (ProofVerdict.HEAD_FAILED, ProofVerdict.BASE_UNAVAILABLE)
185
+ for each_verdict in all_verdicts
186
+ ):
187
+ return FAILED_EXIT_CODE
188
+ if ProofVerdict.PROVEN in all_verdicts:
189
+ return PASSED_EXIT_CODE
190
+ sys.stderr.write(NO_BASE_FAILURE_MESSAGE.format(revision=base_revision) + "\n")
191
+ return FAILED_EXIT_CODE
102
192
 
103
193
 
104
194
  def main(all_arguments: list[str]) -> int:
@@ -121,11 +211,22 @@ def main(all_arguments: list[str]) -> int:
121
211
  ):
122
212
  sys.stdout.write(NO_PRODUCTION_CHANGE_MESSAGE + "\n")
123
213
  return PASSED_EXIT_CODE
124
- all_changed_tests = _changed_python_tests(all_changed_paths)
125
- if not all_changed_tests:
214
+ all_changed_python_tests = _changed_python_tests(all_changed_paths)
215
+ all_changed_node_tests = _changed_node_tests(all_changed_paths)
216
+ if not all_changed_python_tests and not all_changed_node_tests:
126
217
  sys.stderr.write(NO_CHANGED_TEST_MESSAGE + "\n")
127
218
  return FAILED_EXIT_CODE
128
- return _proof_exit_code(all_changed_tests, repository_root, parsed_arguments.base_revision)
219
+ base_revision = parsed_arguments.base_revision
220
+ all_verdicts: list[ProofVerdict] = []
221
+ if all_changed_python_tests:
222
+ all_verdicts.append(
223
+ _python_proof_verdict(all_changed_python_tests, repository_root, base_revision)
224
+ )
225
+ if all_changed_node_tests:
226
+ all_verdicts.append(
227
+ _node_proof_verdict(all_changed_node_tests, repository_root, base_revision)
228
+ )
229
+ return _proof_exit_code(all_verdicts, base_revision)
129
230
 
130
231
 
131
232
  if __name__ == "__main__":
@@ -15,6 +15,10 @@ ALL_PRODUCTION_CODE_SUFFIXES: tuple[str, ...] = (
15
15
  ".ps1",
16
16
  ".sh",
17
17
  )
18
+ ALL_NODE_TEST_SUFFIXES: tuple[str, ...] = (".test.mjs", ".test.js", ".test.cjs")
19
+ ALL_NODE_TEST_COMMAND: tuple[str, ...] = ("node", "--test")
20
+ NODE_BASE_WORKTREE_TEMP_DIRECTORY_PREFIX: str = "fix_pr_test_proof_node_base_"
21
+ NODE_BASE_WORKTREE_DIRECTORY_NAME: str = "tree"
18
22
  CI_CONFIG_DIRECTORY_NAME: str = ".github"
19
23
  PASSED_EXIT_CODE: int = 0
20
24
  FAILED_EXIT_CODE: int = 1
@@ -24,7 +28,7 @@ NO_PRODUCTION_CHANGE_MESSAGE: str = (
24
28
  "fix_pr_test_proof: the fix changes no production code; nothing to prove."
25
29
  )
26
30
  NO_CHANGED_TEST_MESSAGE: str = (
27
- "fix_pr_test_proof: this fix changes production code and no Python test. "
31
+ "fix_pr_test_proof: this fix changes production code and no Python or Node test. "
28
32
  "Add a test that fails on the base and passes on the head."
29
33
  )
30
34
  WORKTREE_FAILED_MESSAGE: str = (
@@ -1,7 +1,7 @@
1
1
  """Behavioral tests for the fix pull request test-proof check.
2
2
 
3
- Every case drives a git repository under ``tmp_path`` and a pytest subprocess,
4
- the same shape the CI job runs.
3
+ Every case drives a git repository under ``tmp_path`` and a pytest or
4
+ ``node --test`` subprocess, the same shape the CI job runs.
5
5
  """
6
6
 
7
7
  from pathlib import Path
@@ -27,6 +27,18 @@ PROOF_TEST_TEXT = (
27
27
  ALWAYS_PASSING_TEST_TEXT = (
28
28
  "def test_nothing_in_particular() -> None:\n assert True\n"
29
29
  )
30
+ BUGGY_NODE_PRODUCTION_TEXT = "export function add(left, right) {\n return left - right;\n}\n"
31
+ FIXED_NODE_PRODUCTION_TEXT = "export function add(left, right) {\n return left + right;\n}\n"
32
+ NODE_PROOF_TEST_TEXT = (
33
+ "import { test } from 'node:test';\n"
34
+ "import { strict as assert } from 'node:assert';\n"
35
+ "import { add } from './calc.mjs';\n\n"
36
+ "test('add sums both operands', () => {\n assert.equal(add(1, 2), 3);\n});\n"
37
+ )
38
+ NODE_ALWAYS_PASSING_TEST_TEXT = (
39
+ "import { test } from 'node:test';\n\n"
40
+ "test('nothing in particular', () => {});\n"
41
+ )
30
42
 
31
43
 
32
44
  def _commit_files(repository_root: Path, all_file_texts: dict[str, str]) -> None:
@@ -48,6 +60,12 @@ def _repository_at_buggy_base(tmp_path: Path) -> tuple[Path, str]:
48
60
  return repository_root, _head_revision(repository_root)
49
61
 
50
62
 
63
+ def _repository_at_buggy_node_base(tmp_path: Path) -> tuple[Path, str]:
64
+ repository_root = repository_with_root_pytest_config(tmp_path)
65
+ _commit_files(repository_root, {"bin/calc.mjs": BUGGY_NODE_PRODUCTION_TEXT})
66
+ return repository_root, _head_revision(repository_root)
67
+
68
+
51
69
  def _run_check(repository_root: Path, title: str, base_revision: str) -> int:
52
70
  return fix_pr_test_proof.main(
53
71
  [
@@ -82,7 +100,7 @@ def test_a_fix_with_no_changed_test_fails(
82
100
  _commit_files(repository_root, {"pkg/calc.py": FIXED_PRODUCTION_TEXT})
83
101
 
84
102
  assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
85
- assert "no Python test" in capsys.readouterr().err
103
+ assert "no Python or Node test" in capsys.readouterr().err
86
104
 
87
105
 
88
106
  def test_a_fix_whose_test_fails_on_base_and_passes_on_head_passes(
@@ -124,3 +142,45 @@ def test_a_fix_whose_changed_test_fails_on_head_fails(
124
142
 
125
143
  assert _run_check(repository_root, "fix: sum operands", base_revision) == 1
126
144
  assert "fails on the head" in capsys.readouterr().err
145
+
146
+
147
+ def test_a_node_fix_whose_test_fails_on_base_and_passes_on_head_passes(
148
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
149
+ ) -> None:
150
+ repository_root, base_revision = _repository_at_buggy_node_base(tmp_path)
151
+ _commit_files(
152
+ repository_root,
153
+ {"bin/calc.mjs": FIXED_NODE_PRODUCTION_TEXT, "bin/calc.test.mjs": NODE_PROOF_TEST_TEXT},
154
+ )
155
+
156
+ assert _run_check(repository_root, "fix(bin): sum operands", base_revision) == 0
157
+ assert "fail on the base and pass on the head" in capsys.readouterr().out
158
+
159
+
160
+ def test_a_node_fix_whose_changed_test_also_passes_on_base_fails(
161
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
162
+ ) -> None:
163
+ repository_root, base_revision = _repository_at_buggy_node_base(tmp_path)
164
+ _commit_files(
165
+ repository_root,
166
+ {
167
+ "bin/calc.mjs": FIXED_NODE_PRODUCTION_TEXT,
168
+ "bin/calc.test.mjs": NODE_ALWAYS_PASSING_TEST_TEXT,
169
+ },
170
+ )
171
+
172
+ assert _run_check(repository_root, "fix(bin): sum operands", base_revision) == 1
173
+ assert "passes on the base" in capsys.readouterr().err
174
+
175
+
176
+ def test_a_node_fix_whose_changed_test_fails_on_head_fails(
177
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
178
+ ) -> None:
179
+ repository_root, base_revision = _repository_at_buggy_node_base(tmp_path)
180
+ _commit_files(
181
+ repository_root,
182
+ {"bin/calc.test.mjs": NODE_PROOF_TEST_TEXT, "bin/extra.mjs": "export const VALUE = 1;\n"},
183
+ )
184
+
185
+ assert _run_check(repository_root, "fix(bin): sum operands", base_revision) == 1
186
+ assert "fails on the head" in capsys.readouterr().err
@@ -1,13 +1,29 @@
1
- import { lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { lstatSync, mkdirSync, readFileSync, readlinkSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
3
 
4
4
  export const SKILL_LOAD_BLOCK_START = '<!-- claude-dev-env skill load: start -->';
5
5
  export const SKILL_LOAD_BLOCK_END = '<!-- claude-dev-env skill load: end -->';
6
6
  export const SKILL_LOAD_INSTRUCTION = 'Load the pstack:poteto-mode skill before your first tool call or reply: '
7
7
  + 'open its SKILL.md from your skills list and follow it. This binds every Codex agent, '
8
8
  + 'including a helper started with spawn_agent, whatever its task message says.';
9
+ export const PACKAGE_GUIDANCE_BLOCK_START = '<!-- claude-dev-env package guidance: start -->';
10
+ export const PACKAGE_GUIDANCE_BLOCK_END = '<!-- claude-dev-env package guidance: end -->';
9
11
 
10
- const SKILL_LOAD_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
12
+ const WINDOWS_LONG_PATH_PREFIX = '\\\\?\\';
13
+
14
+ function managedBlock(blockStart, blockBody, blockEnd) {
15
+ const terminatedBody = blockBody.endsWith('\n') ? blockBody : `${blockBody}\n`;
16
+ return `${blockStart}\n${terminatedBody}${blockEnd}\n`;
17
+ }
18
+
19
+ function withBlockReplaced(guidanceText, blockStart, blockEnd, block) {
20
+ const startIndex = guidanceText.indexOf(blockStart);
21
+ const endIndex = startIndex === -1 ? -1 : guidanceText.indexOf(blockEnd, startIndex);
22
+ if (endIndex === -1) return null;
23
+ let afterBlockIndex = endIndex + blockEnd.length;
24
+ if (guidanceText[afterBlockIndex] === '\n') afterBlockIndex += 1;
25
+ return guidanceText.slice(0, startIndex) + block + guidanceText.slice(afterBlockIndex);
26
+ }
11
27
 
12
28
  /**
13
29
  * Return the Codex guidance text with the package's skill-load block in it.
@@ -17,29 +33,61 @@ const SKILL_LOAD_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n
17
33
  * the text is replaced where it stands; otherwise the block goes first.
18
34
  */
19
35
  export function withSkillLoadBlock(guidanceText) {
20
- const startIndex = guidanceText.indexOf(SKILL_LOAD_BLOCK_START);
21
- const endIndex = startIndex === -1 ? -1 : guidanceText.indexOf(SKILL_LOAD_BLOCK_END, startIndex);
22
- if (endIndex === -1) {
23
- return guidanceText ? `${SKILL_LOAD_BLOCK}\n${guidanceText}` : SKILL_LOAD_BLOCK;
24
- }
25
- let afterBlockIndex = endIndex + SKILL_LOAD_BLOCK_END.length;
26
- if (guidanceText[afterBlockIndex] === '\n') afterBlockIndex += 1;
27
- return guidanceText.slice(0, startIndex) + SKILL_LOAD_BLOCK + guidanceText.slice(afterBlockIndex);
36
+ const block = managedBlock(SKILL_LOAD_BLOCK_START, SKILL_LOAD_INSTRUCTION, SKILL_LOAD_BLOCK_END);
37
+ const replacedText = withBlockReplaced(guidanceText, SKILL_LOAD_BLOCK_START, SKILL_LOAD_BLOCK_END, block);
38
+ if (replacedText !== null) return replacedText;
39
+ return guidanceText ? `${block}\n${guidanceText}` : block;
40
+ }
41
+
42
+ function withPackageGuidanceBlock(guidanceText, packageGuidanceText) {
43
+ const block = managedBlock(PACKAGE_GUIDANCE_BLOCK_START, packageGuidanceText, PACKAGE_GUIDANCE_BLOCK_END);
44
+ const replacedText = withBlockReplaced(
45
+ guidanceText,
46
+ PACKAGE_GUIDANCE_BLOCK_START,
47
+ PACKAGE_GUIDANCE_BLOCK_END,
48
+ block,
49
+ );
50
+ return replacedText ?? `${guidanceText}\n${block}`;
51
+ }
52
+
53
+ function comparablePath(filePath) {
54
+ const withoutPrefix = filePath.startsWith(WINDOWS_LONG_PATH_PREFIX)
55
+ ? filePath.slice(WINDOWS_LONG_PATH_PREFIX.length)
56
+ : filePath;
57
+ const resolvedPath = resolve(withoutPrefix);
58
+ return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
59
+ }
60
+
61
+ function linksToPackageGuidance(agentsPath, allPackageGuidancePaths) {
62
+ const linkTarget = comparablePath(resolve(dirname(agentsPath), readlinkSync(agentsPath)));
63
+ return allPackageGuidancePaths.some(eachPath => comparablePath(eachPath) === linkTarget);
28
64
  }
29
65
 
30
66
  /**
31
- * Write the skill-load block into CODEX_HOME/AGENTS.md.
67
+ * Keep CODEX_HOME/AGENTS.md carrying the skill-load block.
68
+ *
69
+ * A symbolic link that points at the package guidance (the shared agents-home
70
+ * copy, or the retired copy under the Claude home) is replaced by a regular
71
+ * file holding the skill-load block and a package-guidance block, so Codex
72
+ * keeps both after the package moves its guidance. A file that already holds
73
+ * the package-guidance block gets the current guidance text. A link to any
74
+ * other file is left alone.
32
75
  *
33
- * Returns the file path when the file changed, and null when it already held
34
- * the current block.
76
+ * Returns the file path when the file changed, and null otherwise.
35
77
  */
36
- export function writeCodexSkillLoadBlock(codexHome) {
78
+ export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
37
79
  const agentsPath = join(codexHome, 'AGENTS.md');
38
- const hasGuidance = lstatSync(agentsPath, { throwIfNoEntry: false }) !== undefined;
39
- const currentText = hasGuidance ? readFileSync(agentsPath, 'utf8') : '';
40
- const updatedText = withSkillLoadBlock(currentText);
41
- if (updatedText === currentText) return null;
80
+ const agentsEntry = lstatSync(agentsPath, { throwIfNoEntry: false });
81
+ const isPackageGuidanceLink = agentsEntry?.isSymbolicLink() ?? false;
82
+ if (isPackageGuidanceLink && !linksToPackageGuidance(agentsPath, allPackageGuidancePaths)) return null;
83
+ const currentText = agentsEntry && !isPackageGuidanceLink ? readFileSync(agentsPath, 'utf8') : '';
84
+ let updatedText = withSkillLoadBlock(currentText);
85
+ if (isPackageGuidanceLink || updatedText.includes(PACKAGE_GUIDANCE_BLOCK_START)) {
86
+ updatedText = withPackageGuidanceBlock(updatedText, packageGuidanceText);
87
+ }
88
+ if (!isPackageGuidanceLink && updatedText === currentText) return null;
42
89
  mkdirSync(codexHome, { recursive: true });
90
+ if (isPackageGuidanceLink) unlinkSync(agentsPath);
43
91
  writeFileSync(agentsPath, updatedText, 'utf8');
44
92
  return agentsPath;
45
93
  }
@@ -1,22 +1,43 @@
1
1
  import { test } from 'node:test';
2
2
  import { strict as assert } from 'node:assert';
3
- import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import {
7
+ PACKAGE_GUIDANCE_BLOCK_END,
8
+ PACKAGE_GUIDANCE_BLOCK_START,
7
9
  SKILL_LOAD_BLOCK_END,
8
10
  SKILL_LOAD_BLOCK_START,
9
11
  SKILL_LOAD_INSTRUCTION,
10
12
  withSkillLoadBlock,
11
- writeCodexSkillLoadBlock,
13
+ writeCodexAgentsGuidance,
12
14
  } from './codex-skill-load-block.mjs';
13
15
 
14
16
  const EXPECTED_BLOCK = `${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n${SKILL_LOAD_BLOCK_END}\n`;
17
+ const PACKAGE_GUIDANCE = 'Status\n\nChanged / proof / blocked.\n';
15
18
 
16
- function makeCodexHome(context) {
19
+ function guidanceBlock(guidanceText) {
20
+ return `${PACKAGE_GUIDANCE_BLOCK_START}\n${guidanceText}${PACKAGE_GUIDANCE_BLOCK_END}\n`;
21
+ }
22
+
23
+ function makeHomes(context) {
17
24
  const root = mkdtempSync(join(tmpdir(), 'cde-skill-load-'));
18
25
  context.after(() => rmSync(root, { recursive: true, force: true }));
19
- return join(root, '.codex');
26
+ const codexHome = join(root, '.codex');
27
+ const retiredGuidancePath = join(root, '.claude', 'AGENTS.md');
28
+ const sharedGuidancePath = join(root, '.agents', 'AGENTS.md');
29
+ return {
30
+ root,
31
+ codexHome,
32
+ agentsPath: join(codexHome, 'AGENTS.md'),
33
+ retiredGuidancePath,
34
+ sharedGuidancePath,
35
+ allPackageGuidancePaths: [retiredGuidancePath, sharedGuidancePath],
36
+ };
37
+ }
38
+
39
+ function writeGuidance(homes) {
40
+ return writeCodexAgentsGuidance(homes.codexHome, PACKAGE_GUIDANCE, homes.allPackageGuidancePaths);
20
41
  }
21
42
 
22
43
  test('the instruction names the skill and binds spawned helpers', () => {
@@ -24,37 +45,37 @@ test('the instruction names the skill and binds spawned helpers', () => {
24
45
  assert.match(SKILL_LOAD_INSTRUCTION, /spawn_agent/);
25
46
  });
26
47
 
27
- test('a missing Codex guidance file is created holding only the block', (context) => {
28
- const codexHome = makeCodexHome(context);
48
+ test('a missing Codex guidance file is created holding only the skill-load block', (context) => {
49
+ const homes = makeHomes(context);
29
50
 
30
- const writtenPath = writeCodexSkillLoadBlock(codexHome);
51
+ const writtenPath = writeGuidance(homes);
31
52
 
32
- assert.equal(writtenPath, join(codexHome, 'AGENTS.md'));
33
- assert.equal(readFileSync(writtenPath, 'utf8'), EXPECTED_BLOCK);
53
+ assert.equal(writtenPath, homes.agentsPath);
54
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), EXPECTED_BLOCK);
34
55
  });
35
56
 
36
- test('existing guidance keeps every line and gains the block first', (context) => {
37
- const codexHome = makeCodexHome(context);
38
- mkdirSync(codexHome);
39
- writeFileSync(join(codexHome, 'AGENTS.md'), '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
57
+ test('existing guidance keeps every line and gains the skill-load block first', (context) => {
58
+ const homes = makeHomes(context);
59
+ mkdirSync(homes.codexHome);
60
+ writeFileSync(homes.agentsPath, '# pstack model configuration\n\nbug-fix: gpt-6-astra\n');
40
61
 
41
- writeCodexSkillLoadBlock(codexHome);
62
+ writeGuidance(homes);
42
63
 
43
64
  assert.equal(
44
- readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'),
65
+ readFileSync(homes.agentsPath, 'utf8'),
45
66
  `${EXPECTED_BLOCK}\n# pstack model configuration\n\nbug-fix: gpt-6-astra\n`,
46
67
  );
47
68
  });
48
69
 
49
70
  test('a repeat install leaves the file unchanged and reports no write', (context) => {
50
- const codexHome = makeCodexHome(context);
51
- mkdirSync(codexHome);
52
- writeFileSync(join(codexHome, 'AGENTS.md'), 'Custom agents\n');
53
- writeCodexSkillLoadBlock(codexHome);
54
- const afterFirstInstall = readFileSync(join(codexHome, 'AGENTS.md'), 'utf8');
55
-
56
- assert.equal(writeCodexSkillLoadBlock(codexHome), null);
57
- assert.equal(readFileSync(join(codexHome, 'AGENTS.md'), 'utf8'), afterFirstInstall);
71
+ const homes = makeHomes(context);
72
+ mkdirSync(homes.codexHome);
73
+ writeFileSync(homes.agentsPath, 'Custom agents\n');
74
+ writeGuidance(homes);
75
+ const afterFirstInstall = readFileSync(homes.agentsPath, 'utf8');
76
+
77
+ assert.equal(writeGuidance(homes), null);
78
+ assert.equal(readFileSync(homes.agentsPath, 'utf8'), afterFirstInstall);
58
79
  });
59
80
 
60
81
  test('an outdated block is replaced in place and the text around it is kept', () => {
@@ -66,7 +87,48 @@ test('an outdated block is replaced in place and the text around it is kept', ()
66
87
  test('a start marker with no end marker gets a fresh block and keeps the stray text', () => {
67
88
  const broken = `${SKILL_LOAD_BLOCK_START}\nHalf a block\n`;
68
89
 
69
- const repaired = withSkillLoadBlock(broken);
90
+ assert.equal(withSkillLoadBlock(broken), `${EXPECTED_BLOCK}\n${broken}`);
91
+ });
92
+
93
+ for (const linkName of ['retiredGuidancePath', 'sharedGuidancePath']) {
94
+ test(`a link to package guidance at ${linkName} becomes a file with both blocks`, (context) => {
95
+ const homes = makeHomes(context);
96
+ mkdirSync(homes.codexHome);
97
+ symlinkSync(homes[linkName], homes.agentsPath);
98
+
99
+ const writtenPath = writeGuidance(homes);
100
+
101
+ assert.equal(writtenPath, homes.agentsPath);
102
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), false);
103
+ assert.equal(
104
+ readFileSync(homes.agentsPath, 'utf8'),
105
+ `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
106
+ );
107
+ });
108
+ }
109
+
110
+ test('a converted file takes the new package guidance on the next install', (context) => {
111
+ const homes = makeHomes(context);
112
+ mkdirSync(homes.codexHome);
113
+ writeFileSync(homes.agentsPath, `${EXPECTED_BLOCK}\n${guidanceBlock('Old guidance\n')}`);
114
+
115
+ writeGuidance(homes);
116
+
117
+ assert.equal(
118
+ readFileSync(homes.agentsPath, 'utf8'),
119
+ `${EXPECTED_BLOCK}\n${guidanceBlock(PACKAGE_GUIDANCE)}`,
120
+ );
121
+ });
70
122
 
71
- assert.equal(repaired, `${EXPECTED_BLOCK}\n${broken}`);
123
+ test('a link to a file the package does not own is left alone', (context) => {
124
+ const homes = makeHomes(context);
125
+ mkdirSync(homes.codexHome);
126
+ const ownGuidancePath = join(homes.root, 'notes', 'codex.md');
127
+ mkdirSync(join(homes.root, 'notes'));
128
+ writeFileSync(ownGuidancePath, 'My own Codex notes\n');
129
+ symlinkSync(ownGuidancePath, homes.agentsPath);
130
+
131
+ assert.equal(writeGuidance(homes), null);
132
+ assert.equal(lstatSync(homes.agentsPath).isSymbolicLink(), true);
133
+ assert.equal(readFileSync(ownGuidancePath, 'utf8'), 'My own Codex notes\n');
72
134
  });
package/bin/install.mjs CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  shouldInstallPstackPlugin,
46
46
  } from './install-pstack-plugin.mjs';
47
47
  import { seedCodexPstackModels } from './seed-codex-pstack-models.mjs';
48
- import { writeCodexSkillLoadBlock } from './codex-skill-load-block.mjs';
48
+ import { writeCodexAgentsGuidance } from './codex-skill-load-block.mjs';
49
49
  import {
50
50
  continuityHostConfigurationPaths,
51
51
  removeContinuityHooks,
@@ -2788,8 +2788,10 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2788
2788
  allUserOwnedPreferencePaths.add(eachPath);
2789
2789
  }
2790
2790
  }
2791
- const skillLoadGuidancePath = writeCodexSkillLoadBlock(
2791
+ const skillLoadGuidancePath = writeCodexAgentsGuidance(
2792
2792
  INSTALL_ROOT_RESOLUTION.codexHomeDirectory,
2793
+ readFileSync(join(PACKAGE_ROOT, 'AGENTS.md'), 'utf8'),
2794
+ [join(CLAUDE_HOME, 'AGENTS.md'), join(AGENTS_HOME, 'AGENTS.md')],
2793
2795
  );
2794
2796
  if (skillLoadGuidancePath) {
2795
2797
  console.log(` \u2713 ${skillLoadGuidancePath} (Codex skill-load line)`);
@@ -11,6 +11,7 @@ import {
11
11
  readdirSync,
12
12
  existsSync,
13
13
  copyFileSync,
14
+ lstatSync,
14
15
  } from 'node:fs';
15
16
  import { tmpdir } from 'node:os';
16
17
  import { join, dirname } from 'node:path';
@@ -3099,6 +3100,25 @@ test('a repeat install preserves Codex pstack configuration', t => {
3099
3100
  assert.equal(readFileSync(join(codexHome, 'pstack-models.md'), 'utf8'), 'session hook: off\n');
3100
3101
  });
3101
3102
 
3103
+ test('a Codex guidance link to the retired Claude copy becomes a file Codex can read', t => {
3104
+ const sandbox = pstackPluginSandbox(t);
3105
+ const codexHome = join(sandbox.homeDirectory, '.codex');
3106
+ const codexAgentsPath = join(codexHome, 'AGENTS.md');
3107
+ mkdirSync(codexHome, { recursive: true });
3108
+ symlinkSync(join(sandbox.homeDirectory, '.claude', 'AGENTS.md'), codexAgentsPath);
3109
+
3110
+ runPstackInstaller(sandbox.homeDirectory, [], sandbox.environment);
3111
+
3112
+ const packageGuidance = readFileSync(
3113
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'AGENTS.md'),
3114
+ 'utf8',
3115
+ );
3116
+ assert.equal(lstatSync(codexAgentsPath).isSymbolicLink(), false);
3117
+ const codexGuidance = readFileSync(codexAgentsPath, 'utf8');
3118
+ assert.ok(codexGuidance.startsWith(`${SKILL_LOAD_BLOCK_START}\n${SKILL_LOAD_INSTRUCTION}\n`));
3119
+ assert.ok(codexGuidance.includes(packageGuidance));
3120
+ });
3121
+
3102
3122
  test('an absent host command is reported and leaves the other host installed', t => {
3103
3123
  const sandbox = pstackPluginSandbox(t);
3104
3124
 
@@ -64,7 +64,7 @@ def repository_allowlisted_values(repository_root: Path) -> frozenset[str]:
64
64
  The exact literal values the repository's commits and writes may carry
65
65
  past the PII scan, or an empty set when the repository has none.
66
66
  """
67
- origin_slug = _repository_origin_slug(repository_root)
67
+ origin_slug = repository_origin_slug(repository_root)
68
68
  if origin_slug is None:
69
69
  return frozenset()
70
70
  return pii_allowlisted_values_by_repository().get(origin_slug, frozenset())
@@ -171,7 +171,7 @@ def _owner_repo_slug_from_origin_url(origin_url: str) -> str | None:
171
171
  return _owner_repo_slug_from_path(repository_path)
172
172
 
173
173
 
174
- def _repository_origin_slug(repository_root: Path) -> str | None:
174
+ def repository_origin_slug(repository_root: Path) -> str | None:
175
175
  """Return the lowercased owner/repo slug of the origin remote, or None.
176
176
 
177
177
  Args:
@@ -211,7 +211,7 @@ def _is_repository_exempt_from_pii_scan(repository_root: Path) -> bool:
211
211
  Returns:
212
212
  True when the repository's origin slug is in the exempt set.
213
213
  """
214
- origin_slug = _repository_origin_slug(repository_root)
214
+ origin_slug = repository_origin_slug(repository_root)
215
215
  if origin_slug is None:
216
216
  return False
217
217
  return origin_slug in pii_exempt_repository_slugs()
@@ -19,6 +19,7 @@ from pii_prevention_blocker_parts.repository_exemption import (
19
19
  _is_repository_exempt_from_pii_scan,
20
20
  _owner_repo_slug_from_origin_url,
21
21
  repository_allowlisted_values,
22
+ repository_origin_slug,
22
23
  )
23
24
 
24
25
  _ALLOWED_VALUE = "owner.fixture" + "@" + "acme-corp" + ".example" + ".io"
@@ -103,3 +104,9 @@ def test_repository_without_origin_is_never_exempt(tmp_path: Path) -> None:
103
104
 
104
105
  def test_owner_repo_slug_strips_a_trailing_slash_origin() -> None:
105
106
  assert _owner_repo_slug_from_origin_url("https://github.com/Owner/Repo/") == "owner/repo"
107
+
108
+
109
+ def test_repository_origin_slug_reads_the_origin_remote(tmp_path: Path) -> None:
110
+ repository_root = tmp_path / "repo"
111
+ _init_repo_with_github_origin(repository_root, "SlugOwner/slug-repo")
112
+ assert repository_origin_slug(repository_root) == "slugowner/slug-repo"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "8.11.0",
3
+ "version": "8.11.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@ correction arrives a second time, per
32
32
  ## Where it is enforced
33
33
 
34
34
  The staged policy lint's `uncalled-new-file` rule reads each code file a change
35
- adds under `scripts/`, `hooks/`, or `bin/`. It reports the file when its name
35
+ adds under `scripts/`, `hooks/`, `bin/`, `ci/`, or `tools/`. It reports the file when its name
36
36
  appears only in its own tests, `CHANGELOG.md`, `README.md`, and
37
37
  `bin/ever-shipped-skills.mjs`. CI runs that lint against the merge base, so a
38
38
  file with no caller turns the pull request red.
package/rules/testing.md CHANGED
@@ -29,4 +29,4 @@ Red, green, refactor is the default loop for a bug fix and for new behavior. The
29
29
 
30
30
  ## A Fix Carries Its Proof Test
31
31
 
32
- The `Fix test proof` job in `.github/workflows/pr-check.yml` runs `_shared/pr-loop/scripts/fix_pr_test_proof.py` on every pull request whose title starts with `fix`. A fix that changes production code must change at least one Python test that fails on the base and passes on the head. A fix that changes only docs or CI config passes. Version one reads Python tests only, so a fix proven by a JavaScript or PowerShell test needs a Python test beside it.
32
+ The `Fix test proof` job in `.github/workflows/pr-check.yml` runs `_shared/pr-loop/scripts/fix_pr_test_proof.py` on every pull request whose title starts with `fix`. A fix that changes production code must change at least one Python or Node test that fails on the base and passes on the head. Node tests are files ending in `.test.mjs`, `.test.js`, or `.test.cjs`, and the job runs them with `node --test`. A fix that changes only docs or CI config passes. A fix proven by a PowerShell test needs a Python or Node test beside it.
@@ -179,7 +179,7 @@ UNCALLED_NEW_FILE_MESSAGE = (
179
179
  " workflow, skill, command or import in this change, or drop it."
180
180
  )
181
181
  ALL_CALLED_CODE_SUFFIXES = frozenset({".py", ".mjs", ".js", ".sh", ".ps1"})
182
- ALL_CALLED_CODE_SEGMENTS = ("/scripts/", "/hooks/", "/bin/")
182
+ ALL_CALLED_CODE_SEGMENTS = ("/scripts/", "/hooks/", "/bin/", "/ci/", "/tools/")
183
183
  ALL_CALLER_SEARCH_SKIPPED_DIRECTORIES = frozenset(
184
184
  {".git", "node_modules", "__pycache__", ".venv"}
185
185
  )
@@ -13,6 +13,7 @@ from policy_lint.config import constants as policy_constants
13
13
  from private_terms import private_term_line_numbers
14
14
 
15
15
  from repository_checks.config import constants as repository_constants
16
+ from repository_checks.hook_modules import load_hooks_module
16
17
  from repository_checks.models import RepositoryFinding
17
18
 
18
19
 
@@ -25,6 +26,15 @@ def _read_text_or_none(absolute_path: Path) -> str | None:
25
26
  return None
26
27
 
27
28
 
29
+ def _is_owned_by_private_organization(repository_root: Path) -> bool:
30
+ exemption = load_hooks_module(repository_constants.REPOSITORY_EXEMPTION_MODULE_NAME)
31
+ origin_slug = exemption.repository_origin_slug(repository_root)
32
+ if origin_slug is None:
33
+ return False
34
+ owner_name = origin_slug.split(policy_constants.PATH_SEPARATOR)[0]
35
+ return bool(private_term_line_numbers(owner_name, ALL_PRIVATE_TERM_DIGESTS))
36
+
37
+
28
38
  def collect_tracked_private_term_findings(
29
39
  repository_root: Path, all_tracked_paths: Sequence[str]
30
40
  ) -> list[RepositoryFinding]:
@@ -35,8 +45,12 @@ def collect_tracked_private_term_findings(
35
45
  all_tracked_paths: Repository-relative tracked paths.
36
46
 
37
47
  Returns:
38
- Findings that carry the path and line number, never the name.
48
+ Findings that carry the path and line number, never the name. A
49
+ repository whose github.com origin owner is itself a private
50
+ organization returns none, since that organization may name itself.
39
51
  """
52
+ if _is_owned_by_private_organization(repository_root):
53
+ return []
40
54
  all_findings: list[RepositoryFinding] = []
41
55
  for each_relative_path in all_tracked_paths:
42
56
  if (content := _read_text_or_none(repository_root / each_relative_path)) is None:
@@ -124,3 +124,17 @@ def test_should_run_on_staged_and_base_selections() -> None:
124
124
  assert uncalled_rule.selections == frozenset(
125
125
  {SelectionKind.STAGED, SelectionKind.BASE}
126
126
  )
127
+
128
+
129
+ def test_should_report_new_ci_and_tools_helpers_only_tests_name(
130
+ tmp_path: Path,
131
+ ) -> None:
132
+ ci_helper = ".github/ci/lonely_router.py"
133
+ tools_helper = "tools/lonely_archiver.py"
134
+ _write(tmp_path, ci_helper, "print('hi')\n")
135
+ _write(tmp_path, tools_helper, "print('hi')\n")
136
+ _write(tmp_path, ".github/ci/test_lonely_router.py", "import lonely_router\n")
137
+
138
+ all_messages = _messages(tmp_path, (_added(ci_helper), _added(tools_helper)))
139
+
140
+ assert len(all_messages) == 2
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import hashlib
6
+ import subprocess
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
@@ -69,3 +70,37 @@ def test_should_pass_a_tree_that_names_no_private_organization(
69
70
  exit_code, stdout_text, _stderr_text = run_policy(repository_root)
70
71
  assert exit_code == SUCCESS_EXIT_CODE
71
72
  assert stdout_text == ""
73
+
74
+
75
+ def test_should_pass_a_repository_the_named_organization_owns(
76
+ tmp_path: Path,
77
+ ) -> None:
78
+ repository_root = tmp_path / "repo"
79
+ initialize_repository(repository_root)
80
+ subprocess.run(
81
+ ["git", "remote", "add", "origin", "https://github.com/AcmeWidget/app.git"],
82
+ cwd=repository_root,
83
+ check=True,
84
+ )
85
+ write_text(repository_root / "docs" / "notes.md", "Built by Acme Widgets.\n")
86
+ commit_tracked_files(repository_root)
87
+ exit_code, stdout_text, _stderr_text = run_policy(repository_root)
88
+ assert exit_code == SUCCESS_EXIT_CODE
89
+ assert stdout_text == ""
90
+
91
+
92
+ def test_should_flag_a_repository_another_owner_holds(
93
+ tmp_path: Path,
94
+ ) -> None:
95
+ repository_root = tmp_path / "repo"
96
+ initialize_repository(repository_root)
97
+ subprocess.run(
98
+ ["git", "remote", "add", "origin", "https://github.com/someone/app.git"],
99
+ cwd=repository_root,
100
+ check=True,
101
+ )
102
+ write_text(repository_root / "docs" / "notes.md", "Built by Acme Widgets.\n")
103
+ commit_tracked_files(repository_root)
104
+ exit_code, stdout_text, _stderr_text = run_policy(repository_root)
105
+ assert exit_code == FINDINGS_EXIT_CODE
106
+ assert f"{CHECK_ID_TRACKED_PRIVATE_TERMS}: docs/notes.md:" in stdout_text