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.
- package/_shared/pr-loop/scripts/fix_pr_test_proof.py +113 -12
- package/_shared/pr-loop/scripts/pr_loop_shared_constants/fix_pr_test_proof_constants.py +5 -1
- package/_shared/pr-loop/scripts/test_fix_pr_test_proof.py +63 -3
- package/bin/codex-skill-load-block.mjs +67 -19
- package/bin/codex-skill-load-block.test.mjs +87 -25
- package/bin/install.mjs +4 -2
- package/bin/install.test.mjs +20 -0
- package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +3 -3
- package/hooks/blocking/tests/test_repository_exemption.py +7 -0
- package/package.json +1 -1
- package/rules/build-needs-a-user.md +1 -1
- package/rules/testing.md +1 -1
- package/scripts/policy_lint/config/constants.py +1 -1
- package/scripts/repository_checks/tracked_private_terms.py +15 -1
- package/scripts/tests/test_policy_lint_rules_callers.py +14 -0
- package/scripts/tests/test_tracked_private_terms.py +35 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
) ->
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
21
|
-
const
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
*
|
|
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
|
|
34
|
-
* the current block.
|
|
76
|
+
* Returns the file path when the file changed, and null otherwise.
|
|
35
77
|
*/
|
|
36
|
-
export function
|
|
78
|
+
export function writeCodexAgentsGuidance(codexHome, packageGuidanceText, allPackageGuidancePaths) {
|
|
37
79
|
const agentsPath = join(codexHome, 'AGENTS.md');
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
51
|
+
const writtenPath = writeGuidance(homes);
|
|
31
52
|
|
|
32
|
-
assert.equal(writtenPath,
|
|
33
|
-
assert.equal(readFileSync(
|
|
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
|
|
38
|
-
mkdirSync(codexHome);
|
|
39
|
-
writeFileSync(
|
|
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
|
-
|
|
62
|
+
writeGuidance(homes);
|
|
42
63
|
|
|
43
64
|
assert.equal(
|
|
44
|
-
readFileSync(
|
|
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
|
|
51
|
-
mkdirSync(codexHome);
|
|
52
|
-
writeFileSync(
|
|
53
|
-
|
|
54
|
-
const afterFirstInstall = readFileSync(
|
|
55
|
-
|
|
56
|
-
assert.equal(
|
|
57
|
-
assert.equal(readFileSync(
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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)`);
|
package/bin/install.test.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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 =
|
|
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
|
@@ -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 `
|
|
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.
|
|
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
|