codex-workspace-bootstrap 0.6.0__tar.gz → 0.6.1__tar.gz
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.
- {codex_workspace_bootstrap-0.6.0/src/codex_workspace_bootstrap.egg-info → codex_workspace_bootstrap-0.6.1}/PKG-INFO +5 -5
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/README.md +4 -4
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/pyproject.toml +1 -1
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/__init__.py +1 -1
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/agents.py +10 -6
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/audit.py +91 -14
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/doctor.py +5 -1
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/instructions.py +65 -29
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/preflight.py +28 -6
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/sarif.py +4 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1/src/codex_workspace_bootstrap.egg-info}/PKG-INFO +5 -5
- codex_workspace_bootstrap-0.6.1/tests/test_audit.py +208 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_cli.py +35 -1
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_doctor.py +17 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_instruction_integrity.py +89 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_preflight.py +63 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_sarif.py +22 -0
- codex_workspace_bootstrap-0.6.0/tests/test_audit.py +0 -78
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/LICENSE +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/setup.cfg +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/__main__.py +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/cli.py +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/fixes.py +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/SOURCES.txt +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/dependency_links.txt +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/entry_points.txt +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/top_level.txt +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_public_repo_patterns.py +0 -0
- {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_release_consistency.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-workspace-bootstrap
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Preflight AI coding repositories before Codex, Copilot, Cline, Claude, Gemini, Continue, or Cursor touches them.
|
|
5
5
|
Author: kohli217
|
|
6
6
|
License: MIT
|
|
@@ -124,7 +124,7 @@ The goal is not a vanity score. The result is one of:
|
|
|
124
124
|
- license
|
|
125
125
|
- `.gitignore`
|
|
126
126
|
- common project manifests
|
|
127
|
-
- local Git / Python / Node.js /
|
|
127
|
+
- local Git / Python / Node.js / repository-selected package manager / PowerShell / WSL / Codex signals
|
|
128
128
|
|
|
129
129
|
### AI instruction coverage
|
|
130
130
|
|
|
@@ -259,7 +259,7 @@ The reusable Action is published on GitHub Marketplace.
|
|
|
259
259
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
|
260
260
|
with:
|
|
261
261
|
python-version: "3.13"
|
|
262
|
-
- uses: kohli217/codex-workspace-bootstrap@v0.6.
|
|
262
|
+
- uses: kohli217/codex-workspace-bootstrap@v0.6.1
|
|
263
263
|
with:
|
|
264
264
|
path: .
|
|
265
265
|
strict: "true"
|
|
@@ -291,7 +291,7 @@ The intent is to complement those tools, not replace them.
|
|
|
291
291
|
- Suspected secret files are not opened or printed by the filename-risk check.
|
|
292
292
|
- Repository contents are not sent to a remote AI service by the core audit.
|
|
293
293
|
- Instruction files are read locally for deterministic linting; extracted commands are never executed by the integrity lint.
|
|
294
|
-
- Repository instruction/configuration inputs are not
|
|
294
|
+
- Repository instruction/configuration and project-marker inputs are not trusted through symbolic links.
|
|
295
295
|
- `init-agents` and automatic fixes refuse symlinked `AGENTS.md` targets rather than writing through them.
|
|
296
296
|
- Existing regular `AGENTS.md` files are protected unless overwrite is explicit.
|
|
297
297
|
- A passing preflight is evidence about the checks performed, **not a security guarantee**.
|
|
@@ -325,7 +325,7 @@ py -m pip install codex-workspace-bootstrap
|
|
|
325
325
|
Pinned GitHub release artifact:
|
|
326
326
|
|
|
327
327
|
```powershell
|
|
328
|
-
py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.6.
|
|
328
|
+
py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.6.1/codex_workspace_bootstrap-0.6.1-py3-none-any.whl"
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
## Development
|
|
@@ -96,7 +96,7 @@ The goal is not a vanity score. The result is one of:
|
|
|
96
96
|
- license
|
|
97
97
|
- `.gitignore`
|
|
98
98
|
- common project manifests
|
|
99
|
-
- local Git / Python / Node.js /
|
|
99
|
+
- local Git / Python / Node.js / repository-selected package manager / PowerShell / WSL / Codex signals
|
|
100
100
|
|
|
101
101
|
### AI instruction coverage
|
|
102
102
|
|
|
@@ -231,7 +231,7 @@ The reusable Action is published on GitHub Marketplace.
|
|
|
231
231
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
|
232
232
|
with:
|
|
233
233
|
python-version: "3.13"
|
|
234
|
-
- uses: kohli217/codex-workspace-bootstrap@v0.6.
|
|
234
|
+
- uses: kohli217/codex-workspace-bootstrap@v0.6.1
|
|
235
235
|
with:
|
|
236
236
|
path: .
|
|
237
237
|
strict: "true"
|
|
@@ -263,7 +263,7 @@ The intent is to complement those tools, not replace them.
|
|
|
263
263
|
- Suspected secret files are not opened or printed by the filename-risk check.
|
|
264
264
|
- Repository contents are not sent to a remote AI service by the core audit.
|
|
265
265
|
- Instruction files are read locally for deterministic linting; extracted commands are never executed by the integrity lint.
|
|
266
|
-
- Repository instruction/configuration inputs are not
|
|
266
|
+
- Repository instruction/configuration and project-marker inputs are not trusted through symbolic links.
|
|
267
267
|
- `init-agents` and automatic fixes refuse symlinked `AGENTS.md` targets rather than writing through them.
|
|
268
268
|
- Existing regular `AGENTS.md` files are protected unless overwrite is explicit.
|
|
269
269
|
- A passing preflight is evidence about the checks performed, **not a security guarantee**.
|
|
@@ -297,7 +297,7 @@ py -m pip install codex-workspace-bootstrap
|
|
|
297
297
|
Pinned GitHub release artifact:
|
|
298
298
|
|
|
299
299
|
```powershell
|
|
300
|
-
py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.6.
|
|
300
|
+
py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.6.1/codex_workspace_bootstrap-0.6.1-py3-none-any.whl"
|
|
301
301
|
```
|
|
302
302
|
|
|
303
303
|
## Development
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codex-workspace-bootstrap"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Preflight AI coding repositories before Codex, Copilot, Cline, Claude, Gemini, Continue, or Cursor touches them."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -18,11 +18,15 @@ class ValidationCommand:
|
|
|
18
18
|
review_required: bool = False
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def _regular_file(path: Path) -> bool:
|
|
22
|
+
return path.is_file() and not path.is_symlink()
|
|
23
|
+
|
|
24
|
+
|
|
21
25
|
def detect_project_signals(root: Path) -> list[str]:
|
|
22
26
|
signals: list[str] = []
|
|
23
|
-
if any((root / marker)
|
|
27
|
+
if any(_regular_file(root / marker) for marker in PYTHON_MARKERS):
|
|
24
28
|
signals.append("Python")
|
|
25
|
-
if (root / NODE_MARKER)
|
|
29
|
+
if _regular_file(root / NODE_MARKER):
|
|
26
30
|
signals.append("Node.js")
|
|
27
31
|
return signals
|
|
28
32
|
|
|
@@ -44,7 +48,7 @@ def _node_script_names(root: Path) -> set[str]:
|
|
|
44
48
|
return {name for name, value in scripts.items() if isinstance(name, str) and isinstance(value, str)}
|
|
45
49
|
|
|
46
50
|
|
|
47
|
-
def
|
|
51
|
+
def detect_node_package_managers(root: Path) -> set[str]:
|
|
48
52
|
managers: set[str] = set()
|
|
49
53
|
package_json = root / NODE_MARKER
|
|
50
54
|
|
|
@@ -154,8 +158,8 @@ def validation_plan(root: Path) -> list[ValidationCommand]:
|
|
|
154
158
|
if "Python" in signals:
|
|
155
159
|
pytest_evidence = (
|
|
156
160
|
_pyproject_has_pytest(root)
|
|
157
|
-
or (root / "pytest.ini")
|
|
158
|
-
or (root / "conftest.py")
|
|
161
|
+
or _regular_file(root / "pytest.ini")
|
|
162
|
+
or _regular_file(root / "conftest.py")
|
|
159
163
|
)
|
|
160
164
|
if "python -m pytest" in documented or "pytest" in documented:
|
|
161
165
|
plan.append(ValidationCommand("python -m pytest", "documented in README"))
|
|
@@ -174,7 +178,7 @@ def validation_plan(root: Path) -> list[ValidationCommand]:
|
|
|
174
178
|
|
|
175
179
|
if "Node.js" in signals:
|
|
176
180
|
scripts = _node_script_names(root)
|
|
177
|
-
managers =
|
|
181
|
+
managers = detect_node_package_managers(root)
|
|
178
182
|
manager = next(iter(managers)) if len(managers) == 1 else None
|
|
179
183
|
|
|
180
184
|
if manager:
|
|
@@ -2,10 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import asdict, dataclass
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
import os
|
|
5
6
|
import shutil
|
|
6
7
|
import subprocess
|
|
7
8
|
from typing import Iterable
|
|
8
9
|
|
|
10
|
+
from .agents import detect_node_package_managers
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
@dataclass(frozen=True)
|
|
11
14
|
class Check:
|
|
@@ -22,7 +25,6 @@ COMMON_TOOLS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
|
22
25
|
("git", ("git", "--version")),
|
|
23
26
|
("python", ("python", "--version")),
|
|
24
27
|
("node", ("node", "--version")),
|
|
25
|
-
("npm", ("npm", "--version")),
|
|
26
28
|
("powershell", ("powershell", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()")),
|
|
27
29
|
("wsl", ("wsl", "--status")),
|
|
28
30
|
("codex", ("codex", "--version")),
|
|
@@ -49,6 +51,13 @@ RISK_FILENAMES = {
|
|
|
49
51
|
|
|
50
52
|
RISK_SUFFIXES = (".pem", ".p12", ".pfx", ".key")
|
|
51
53
|
|
|
54
|
+
NODE_PACKAGE_MANAGER_COMMANDS: dict[str, tuple[str, ...]] = {
|
|
55
|
+
"npm": ("npm", "--version"),
|
|
56
|
+
"pnpm": ("pnpm", "--version"),
|
|
57
|
+
"yarn": ("yarn", "--version"),
|
|
58
|
+
"bun": ("bun", "--version"),
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
|
|
53
62
|
def _tool_check(label: str, command: tuple[str, ...]) -> Check:
|
|
54
63
|
executable = shutil.which(command[0])
|
|
@@ -73,12 +82,30 @@ def _tool_check(label: str, command: tuple[str, ...]) -> Check:
|
|
|
73
82
|
|
|
74
83
|
|
|
75
84
|
def _iter_project_files(root: Path) -> Iterable[Path]:
|
|
76
|
-
excluded = {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
excluded = {
|
|
86
|
+
".git",
|
|
87
|
+
".venv",
|
|
88
|
+
"venv",
|
|
89
|
+
"node_modules",
|
|
90
|
+
"__pycache__",
|
|
91
|
+
".pytest_cache",
|
|
92
|
+
".mypy_cache",
|
|
93
|
+
".ruff_cache",
|
|
94
|
+
".tox",
|
|
95
|
+
".nox",
|
|
96
|
+
"dist",
|
|
97
|
+
"build",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for current, dirnames, filenames in os.walk(root, followlinks=False):
|
|
101
|
+
current_path = Path(current)
|
|
102
|
+
dirnames[:] = [
|
|
103
|
+
name
|
|
104
|
+
for name in dirnames
|
|
105
|
+
if name not in excluded and not (current_path / name).is_symlink()
|
|
106
|
+
]
|
|
107
|
+
for filename in filenames:
|
|
108
|
+
yield current_path / filename
|
|
82
109
|
|
|
83
110
|
|
|
84
111
|
def _git_matches(root: Path, args: tuple[str, ...]) -> bool:
|
|
@@ -132,7 +159,14 @@ def audit_repository(root: Path) -> list[Check]:
|
|
|
132
159
|
)
|
|
133
160
|
)
|
|
134
161
|
|
|
135
|
-
readme = next(
|
|
162
|
+
readme = next(
|
|
163
|
+
(
|
|
164
|
+
p
|
|
165
|
+
for p in root.iterdir()
|
|
166
|
+
if p.is_file() and not p.is_symlink() and p.name.lower().startswith("readme")
|
|
167
|
+
),
|
|
168
|
+
None,
|
|
169
|
+
)
|
|
136
170
|
checks.append(
|
|
137
171
|
Check(
|
|
138
172
|
"readme",
|
|
@@ -141,7 +175,14 @@ def audit_repository(root: Path) -> list[Check]:
|
|
|
141
175
|
)
|
|
142
176
|
)
|
|
143
177
|
|
|
144
|
-
license_file = next(
|
|
178
|
+
license_file = next(
|
|
179
|
+
(
|
|
180
|
+
p
|
|
181
|
+
for p in root.iterdir()
|
|
182
|
+
if p.is_file() and not p.is_symlink() and p.name.lower().startswith("license")
|
|
183
|
+
),
|
|
184
|
+
None,
|
|
185
|
+
)
|
|
145
186
|
checks.append(
|
|
146
187
|
Check(
|
|
147
188
|
"license",
|
|
@@ -153,20 +194,32 @@ def audit_repository(root: Path) -> list[Check]:
|
|
|
153
194
|
checks.append(
|
|
154
195
|
Check(
|
|
155
196
|
"gitignore",
|
|
156
|
-
"pass"
|
|
157
|
-
|
|
197
|
+
"pass"
|
|
198
|
+
if (root / ".gitignore").is_file() and not (root / ".gitignore").is_symlink()
|
|
199
|
+
else "warn",
|
|
200
|
+
".gitignore detected"
|
|
201
|
+
if (root / ".gitignore").is_file() and not (root / ".gitignore").is_symlink()
|
|
202
|
+
else ".gitignore not found",
|
|
158
203
|
)
|
|
159
204
|
)
|
|
160
205
|
|
|
161
206
|
checks.append(
|
|
162
207
|
Check(
|
|
163
208
|
"agents",
|
|
164
|
-
"pass"
|
|
165
|
-
|
|
209
|
+
"pass"
|
|
210
|
+
if (root / "AGENTS.md").is_file() and not (root / "AGENTS.md").is_symlink()
|
|
211
|
+
else "warn",
|
|
212
|
+
"AGENTS.md detected"
|
|
213
|
+
if (root / "AGENTS.md").is_file() and not (root / "AGENTS.md").is_symlink()
|
|
214
|
+
else "AGENTS.md not found; run init-agents",
|
|
166
215
|
)
|
|
167
216
|
)
|
|
168
217
|
|
|
169
|
-
manifests = [
|
|
218
|
+
manifests = [
|
|
219
|
+
name
|
|
220
|
+
for name in MANIFESTS
|
|
221
|
+
if (root / name).is_file() and not (root / name).is_symlink()
|
|
222
|
+
]
|
|
170
223
|
checks.append(
|
|
171
224
|
Check(
|
|
172
225
|
"project-manifest",
|
|
@@ -178,6 +231,30 @@ def audit_repository(root: Path) -> list[Check]:
|
|
|
178
231
|
for label, command in COMMON_TOOLS:
|
|
179
232
|
checks.append(_tool_check(label, command))
|
|
180
233
|
|
|
234
|
+
if (root / "package.json").is_file() and not (root / "package.json").is_symlink():
|
|
235
|
+
managers = detect_node_package_managers(root)
|
|
236
|
+
if managers:
|
|
237
|
+
if len(managers) > 1:
|
|
238
|
+
checks.append(
|
|
239
|
+
Check(
|
|
240
|
+
"package-manager-evidence",
|
|
241
|
+
"warn",
|
|
242
|
+
"Conflicting Node.js package-manager evidence detected: "
|
|
243
|
+
+ ", ".join(sorted(managers)),
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
for manager in sorted(managers):
|
|
247
|
+
command = NODE_PACKAGE_MANAGER_COMMANDS[manager]
|
|
248
|
+
checks.append(_tool_check(manager, command))
|
|
249
|
+
else:
|
|
250
|
+
checks.append(
|
|
251
|
+
Check(
|
|
252
|
+
"package-manager-evidence",
|
|
253
|
+
"warn",
|
|
254
|
+
"Node.js project detected, but packageManager/lockfile evidence does not confirm npm, pnpm, yarn, or bun",
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
|
|
181
258
|
risky: list[str] = []
|
|
182
259
|
for path in _iter_project_files(root):
|
|
183
260
|
name = path.name.lower()
|
|
@@ -23,7 +23,11 @@ GUIDANCE: dict[str, str] = {
|
|
|
23
23
|
"git": "Verify Git is installed and available on PATH.",
|
|
24
24
|
"python": "Verify Python or the Windows Python launcher is installed and available on PATH.",
|
|
25
25
|
"node": "Verify Node.js is installed and available on PATH when the project requires it.",
|
|
26
|
-
"npm": "Verify npm is available
|
|
26
|
+
"npm": "Verify npm is installed and available on PATH for this repository.",
|
|
27
|
+
"pnpm": "Verify pnpm is installed and available on PATH for this repository.",
|
|
28
|
+
"yarn": "Verify Yarn is installed and available on PATH for this repository.",
|
|
29
|
+
"bun": "Verify Bun is installed and available on PATH for this repository.",
|
|
30
|
+
"package-manager-evidence": "Confirm the intended Node.js package manager in package.json packageManager or with the repository lockfile.",
|
|
27
31
|
"powershell": "Verify Windows PowerShell or PowerShell is available on PATH.",
|
|
28
32
|
"wsl": "Check WSL status only when the repository workflow requires Linux tooling.",
|
|
29
33
|
"codex": "Verify the Codex CLI is installed and available on PATH when local Codex workflows are intended.",
|
|
@@ -6,6 +6,7 @@ import os
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
import posixpath
|
|
8
8
|
import re
|
|
9
|
+
import shlex
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@dataclass(frozen=True)
|
|
@@ -93,11 +94,6 @@ _COMMAND_PATTERNS: tuple[re.Pattern[str], ...] = (
|
|
|
93
94
|
_CODE_FENCE = re.compile(r"```(?:[A-Za-z0-9_+.-]+)?\s*\n(.*?)```", re.S)
|
|
94
95
|
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
|
95
96
|
_PACKAGE_COMMAND = re.compile(r"^(npm|pnpm|yarn|bun)\b", re.I)
|
|
96
|
-
_SCRIPT_COMMAND = re.compile(
|
|
97
|
-
r"^(npm|pnpm|bun)\s+(?:run\s+)?([\w:.-]+)|^yarn\s+(?:run\s+)?([\w:.-]+)",
|
|
98
|
-
re.I,
|
|
99
|
-
)
|
|
100
|
-
|
|
101
97
|
|
|
102
98
|
def _safe_read(path: Path) -> str:
|
|
103
99
|
try:
|
|
@@ -608,29 +604,72 @@ def _manager_for_command(command: str) -> str | None:
|
|
|
608
604
|
return match.group(1).lower() if match else None
|
|
609
605
|
|
|
610
606
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
607
|
+
_PACKAGE_OPTIONS_WITH_VALUE = {
|
|
608
|
+
"--filter",
|
|
609
|
+
"-F",
|
|
610
|
+
"--workspace",
|
|
611
|
+
"-w",
|
|
612
|
+
"--prefix",
|
|
613
|
+
"--dir",
|
|
614
|
+
"-C",
|
|
615
|
+
"--cwd",
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _package_command_tokens(command: str) -> list[str]:
|
|
620
|
+
try:
|
|
621
|
+
return shlex.split(command, posix=True)
|
|
622
|
+
except ValueError:
|
|
623
|
+
return command.split()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _script_for_command(command: str) -> tuple[str, str, bool] | None:
|
|
627
|
+
tokens = _package_command_tokens(command.strip())
|
|
628
|
+
if not tokens:
|
|
614
629
|
return None
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if not
|
|
630
|
+
|
|
631
|
+
manager = tokens[0].lower()
|
|
632
|
+
if manager not in {"npm", "pnpm", "yarn", "bun"}:
|
|
618
633
|
return None
|
|
619
634
|
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
635
|
+
index = 1
|
|
636
|
+
while index < len(tokens):
|
|
637
|
+
token = tokens[index]
|
|
638
|
+
if token in _PACKAGE_OPTIONS_WITH_VALUE:
|
|
639
|
+
index += 2
|
|
640
|
+
continue
|
|
641
|
+
if any(token.startswith(f"{option}=") for option in _PACKAGE_OPTIONS_WITH_VALUE if option.startswith("--")):
|
|
642
|
+
index += 1
|
|
643
|
+
continue
|
|
644
|
+
if token.startswith("-"):
|
|
645
|
+
index += 1
|
|
646
|
+
continue
|
|
647
|
+
break
|
|
648
|
+
|
|
649
|
+
if index >= len(tokens):
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
if manager == "yarn" and tokens[index] == "workspace":
|
|
653
|
+
index += 2
|
|
654
|
+
if index >= len(tokens):
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
explicit_run = tokens[index] in {"run", "run-script"}
|
|
658
|
+
if explicit_run:
|
|
659
|
+
index += 1
|
|
660
|
+
while index < len(tokens) and tokens[index].startswith("-"):
|
|
661
|
+
if tokens[index] in _PACKAGE_OPTIONS_WITH_VALUE:
|
|
662
|
+
index += 2
|
|
663
|
+
else:
|
|
664
|
+
index += 1
|
|
665
|
+
if index >= len(tokens):
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
script = tokens[index]
|
|
669
|
+
if script in {"install", "ci", "exec", "dlx", "workspace"}:
|
|
631
670
|
return None
|
|
632
671
|
|
|
633
|
-
return manager, script
|
|
672
|
+
return manager, script, explicit_run
|
|
634
673
|
|
|
635
674
|
|
|
636
675
|
def _validation_key(command: str) -> str | None:
|
|
@@ -660,7 +699,7 @@ def _validation_key(command: str) -> str | None:
|
|
|
660
699
|
parsed = _script_for_command(lowered)
|
|
661
700
|
if not parsed:
|
|
662
701
|
return None
|
|
663
|
-
manager, script = parsed
|
|
702
|
+
manager, script, _explicit_run = parsed
|
|
664
703
|
family = script.split(":", 1)[0]
|
|
665
704
|
if family in {"test", "lint", "check", "build", "typecheck", "validate", "verify"}:
|
|
666
705
|
return f"{family}:{manager}:{script}"
|
|
@@ -766,11 +805,8 @@ def lint_instructions(
|
|
|
766
805
|
parsed = _script_for_command(command)
|
|
767
806
|
if not parsed:
|
|
768
807
|
continue
|
|
769
|
-
_manager, script = parsed
|
|
770
|
-
if script not in scripts and (
|
|
771
|
-
script == "test"
|
|
772
|
-
or command.lower().startswith(("npm run ", "pnpm run ", "bun run ", "yarn run "))
|
|
773
|
-
):
|
|
808
|
+
_manager, script, explicit_run = parsed
|
|
809
|
+
if script not in scripts and (script == "test" or explicit_run):
|
|
774
810
|
findings.append(
|
|
775
811
|
InstructionFinding(
|
|
776
812
|
"missing-package-script",
|
|
@@ -130,17 +130,39 @@ def next_actions(
|
|
|
130
130
|
)
|
|
131
131
|
|
|
132
132
|
if "Node.js" in project_signals:
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
manager_checks = [
|
|
134
|
+
check
|
|
135
|
+
for check in checks
|
|
136
|
+
if check.name in {"npm", "pnpm", "yarn", "bun"}
|
|
137
|
+
]
|
|
138
|
+
evidence = _check_by_name(checks, "package-manager-evidence")
|
|
139
|
+
if evidence is not None and evidence.status != "pass":
|
|
140
|
+
priority = "P1" if len(manager_checks) > 1 else "P2"
|
|
141
|
+
title = (
|
|
142
|
+
"Resolve conflicting repository package-manager evidence"
|
|
143
|
+
if len(manager_checks) > 1
|
|
144
|
+
else "Confirm the repository package manager"
|
|
145
|
+
)
|
|
135
146
|
actions.append(
|
|
136
147
|
NextAction(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
reason=npm.message,
|
|
148
|
+
priority,
|
|
149
|
+
title,
|
|
150
|
+
reason=evidence.message,
|
|
141
151
|
)
|
|
142
152
|
)
|
|
143
153
|
|
|
154
|
+
if len(manager_checks) == 1:
|
|
155
|
+
manager = manager_checks[0]
|
|
156
|
+
if manager.status != "pass":
|
|
157
|
+
actions.append(
|
|
158
|
+
NextAction(
|
|
159
|
+
"P1",
|
|
160
|
+
f"Restore the Node.js/{manager.name} toolchain required by this repository",
|
|
161
|
+
command=f"{manager.name} --version",
|
|
162
|
+
reason=manager.message,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
|
|
144
166
|
return actions
|
|
145
167
|
|
|
146
168
|
|
|
@@ -17,6 +17,10 @@ RULE_HELP: dict[str, str] = {
|
|
|
17
17
|
"python": "Install Python or make it available on PATH when the repository requires Python.",
|
|
18
18
|
"node": "Install Node.js or make it available on PATH when the repository requires Node.js.",
|
|
19
19
|
"npm": "Install npm or make it available on PATH when the repository requires npm.",
|
|
20
|
+
"pnpm": "Install pnpm or make it available on PATH when the repository requires pnpm.",
|
|
21
|
+
"yarn": "Install Yarn or make it available on PATH when the repository requires Yarn.",
|
|
22
|
+
"bun": "Install Bun or make it available on PATH when the repository requires Bun.",
|
|
23
|
+
"package-manager-evidence": "Align packageManager and lockfile evidence so the repository identifies one intended Node.js package manager.",
|
|
20
24
|
"powershell": "Install or expose PowerShell when Windows PowerShell workflows are required.",
|
|
21
25
|
"wsl": "Install or configure WSL only when the repository workflow requires it.",
|
|
22
26
|
"codex": "Install or expose the Codex CLI when local Codex command checks are required.",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-workspace-bootstrap
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Preflight AI coding repositories before Codex, Copilot, Cline, Claude, Gemini, Continue, or Cursor touches them.
|
|
5
5
|
Author: kohli217
|
|
6
6
|
License: MIT
|
|
@@ -124,7 +124,7 @@ The goal is not a vanity score. The result is one of:
|
|
|
124
124
|
- license
|
|
125
125
|
- `.gitignore`
|
|
126
126
|
- common project manifests
|
|
127
|
-
- local Git / Python / Node.js /
|
|
127
|
+
- local Git / Python / Node.js / repository-selected package manager / PowerShell / WSL / Codex signals
|
|
128
128
|
|
|
129
129
|
### AI instruction coverage
|
|
130
130
|
|
|
@@ -259,7 +259,7 @@ The reusable Action is published on GitHub Marketplace.
|
|
|
259
259
|
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
|
260
260
|
with:
|
|
261
261
|
python-version: "3.13"
|
|
262
|
-
- uses: kohli217/codex-workspace-bootstrap@v0.6.
|
|
262
|
+
- uses: kohli217/codex-workspace-bootstrap@v0.6.1
|
|
263
263
|
with:
|
|
264
264
|
path: .
|
|
265
265
|
strict: "true"
|
|
@@ -291,7 +291,7 @@ The intent is to complement those tools, not replace them.
|
|
|
291
291
|
- Suspected secret files are not opened or printed by the filename-risk check.
|
|
292
292
|
- Repository contents are not sent to a remote AI service by the core audit.
|
|
293
293
|
- Instruction files are read locally for deterministic linting; extracted commands are never executed by the integrity lint.
|
|
294
|
-
- Repository instruction/configuration inputs are not
|
|
294
|
+
- Repository instruction/configuration and project-marker inputs are not trusted through symbolic links.
|
|
295
295
|
- `init-agents` and automatic fixes refuse symlinked `AGENTS.md` targets rather than writing through them.
|
|
296
296
|
- Existing regular `AGENTS.md` files are protected unless overwrite is explicit.
|
|
297
297
|
- A passing preflight is evidence about the checks performed, **not a security guarantee**.
|
|
@@ -325,7 +325,7 @@ py -m pip install codex-workspace-bootstrap
|
|
|
325
325
|
Pinned GitHub release artifact:
|
|
326
326
|
|
|
327
327
|
```powershell
|
|
328
|
-
py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.6.
|
|
328
|
+
py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.6.1/codex_workspace_bootstrap-0.6.1-py3-none-any.whl"
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
## Development
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from codex_workspace_bootstrap.audit import audit_repository, summary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _git(root: Path, *args: str) -> None:
|
|
11
|
+
subprocess.run(
|
|
12
|
+
("git", "-C", str(root), *args),
|
|
13
|
+
check=True,
|
|
14
|
+
capture_output=True,
|
|
15
|
+
text=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_audit_detects_basic_repository_files(tmp_path: Path) -> None:
|
|
20
|
+
(tmp_path / ".git").mkdir()
|
|
21
|
+
(tmp_path / "README.md").write_text("# demo\n", encoding="utf-8")
|
|
22
|
+
(tmp_path / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
|
23
|
+
(tmp_path / ".gitignore").write_text(".env\n", encoding="utf-8")
|
|
24
|
+
(tmp_path / "AGENTS.md").write_text("# agents\n", encoding="utf-8")
|
|
25
|
+
(tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
|
|
26
|
+
|
|
27
|
+
checks = audit_repository(tmp_path)
|
|
28
|
+
by_name = {c.name: c for c in checks}
|
|
29
|
+
|
|
30
|
+
assert by_name["git-repository"].status == "pass"
|
|
31
|
+
assert by_name["readme"].status == "pass"
|
|
32
|
+
assert by_name["license"].status == "pass"
|
|
33
|
+
assert by_name["gitignore"].status == "pass"
|
|
34
|
+
assert by_name["agents"].status == "pass"
|
|
35
|
+
assert by_name["project-manifest"].status == "pass"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_audit_warns_on_secret_risk_filename(tmp_path: Path) -> None:
|
|
39
|
+
(tmp_path / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
40
|
+
checks = audit_repository(tmp_path)
|
|
41
|
+
by_name = {c.name: c for c in checks}
|
|
42
|
+
assert by_name["secret-risk-files"].status == "warn"
|
|
43
|
+
assert by_name["secret-risk-files"].blocking is False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
|
|
47
|
+
def test_tracked_secret_risk_file_is_blocking(tmp_path: Path) -> None:
|
|
48
|
+
_git(tmp_path, "init")
|
|
49
|
+
(tmp_path / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
50
|
+
_git(tmp_path, "add", "-f", ".env")
|
|
51
|
+
|
|
52
|
+
checks = audit_repository(tmp_path)
|
|
53
|
+
by_name = {c.name: c for c in checks}
|
|
54
|
+
|
|
55
|
+
assert by_name["secret-risk-files"].status == "warn"
|
|
56
|
+
assert by_name["secret-risk-files"].blocking is True
|
|
57
|
+
assert "tracked:" in by_name["secret-risk-files"].message
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
|
|
61
|
+
def test_ignored_secret_risk_file_is_not_blocking(tmp_path: Path) -> None:
|
|
62
|
+
_git(tmp_path, "init")
|
|
63
|
+
(tmp_path / ".gitignore").write_text(".env\n", encoding="utf-8")
|
|
64
|
+
(tmp_path / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
65
|
+
|
|
66
|
+
checks = audit_repository(tmp_path)
|
|
67
|
+
by_name = {c.name: c for c in checks}
|
|
68
|
+
|
|
69
|
+
assert by_name["secret-risk-files"].status == "warn"
|
|
70
|
+
assert by_name["secret-risk-files"].blocking is False
|
|
71
|
+
assert "ignored:" in by_name["secret-risk-files"].message
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_summary_counts_statuses(tmp_path: Path) -> None:
|
|
75
|
+
checks = audit_repository(tmp_path)
|
|
76
|
+
totals = summary(checks)
|
|
77
|
+
assert totals["passed"] + totals["warnings"] == len(checks)
|
|
78
|
+
assert totals["blocking"] == 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_audit_checks_detected_pnpm_instead_of_assuming_npm(
|
|
82
|
+
tmp_path: Path,
|
|
83
|
+
monkeypatch,
|
|
84
|
+
) -> None:
|
|
85
|
+
(tmp_path / "package.json").write_text(
|
|
86
|
+
'{"packageManager":"pnpm@10","scripts":{"test":"vitest"}}',
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
(tmp_path / "pnpm-lock.yaml").write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
|
|
90
|
+
|
|
91
|
+
def fake_tool_check(label: str, command: tuple[str, ...]):
|
|
92
|
+
from codex_workspace_bootstrap.audit import Check
|
|
93
|
+
return Check(label, "pass", "available")
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr("codex_workspace_bootstrap.audit._tool_check", fake_tool_check)
|
|
96
|
+
|
|
97
|
+
checks = audit_repository(tmp_path)
|
|
98
|
+
names = {check.name for check in checks}
|
|
99
|
+
|
|
100
|
+
assert "pnpm" in names
|
|
101
|
+
assert "npm" not in names
|
|
102
|
+
assert "package-manager-evidence" not in names
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_audit_reports_unconfirmed_node_package_manager(
|
|
106
|
+
tmp_path: Path,
|
|
107
|
+
monkeypatch,
|
|
108
|
+
) -> None:
|
|
109
|
+
(tmp_path / "package.json").write_text(
|
|
110
|
+
'{"scripts":{"test":"vitest"}}',
|
|
111
|
+
encoding="utf-8",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def fake_tool_check(label: str, command: tuple[str, ...]):
|
|
115
|
+
from codex_workspace_bootstrap.audit import Check
|
|
116
|
+
return Check(label, "pass", "available")
|
|
117
|
+
|
|
118
|
+
monkeypatch.setattr("codex_workspace_bootstrap.audit._tool_check", fake_tool_check)
|
|
119
|
+
|
|
120
|
+
checks = audit_repository(tmp_path)
|
|
121
|
+
by_name = {check.name: check for check in checks}
|
|
122
|
+
|
|
123
|
+
assert by_name["package-manager-evidence"].status == "warn"
|
|
124
|
+
assert "does not confirm" in by_name["package-manager-evidence"].message
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_secret_risk_scan_works_when_repository_parent_is_named_build(tmp_path: Path) -> None:
|
|
128
|
+
root = tmp_path / "build" / "repo"
|
|
129
|
+
root.mkdir(parents=True)
|
|
130
|
+
(root / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
checks = audit_repository(root)
|
|
133
|
+
by_name = {check.name: check for check in checks}
|
|
134
|
+
|
|
135
|
+
assert by_name["secret-risk-files"].status == "warn"
|
|
136
|
+
assert ".env" in by_name["secret-risk-files"].message
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_secret_risk_scan_prunes_generated_directories(tmp_path: Path) -> None:
|
|
140
|
+
ignored = tmp_path / "node_modules" / "pkg"
|
|
141
|
+
ignored.mkdir(parents=True)
|
|
142
|
+
(ignored / ".env").write_text("EXAMPLE=dependency-file\n", encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
cache = tmp_path / ".pytest_cache"
|
|
145
|
+
cache.mkdir()
|
|
146
|
+
(cache / "credentials.json").write_text("{}", encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
checks = audit_repository(tmp_path)
|
|
149
|
+
by_name = {check.name: check for check in checks}
|
|
150
|
+
|
|
151
|
+
assert by_name["secret-risk-files"].status == "pass"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_audit_reports_conflicting_node_package_manager_evidence(
|
|
155
|
+
tmp_path: Path,
|
|
156
|
+
monkeypatch,
|
|
157
|
+
) -> None:
|
|
158
|
+
(tmp_path / "package.json").write_text(
|
|
159
|
+
'{"packageManager":"pnpm@10","scripts":{"test":"vitest"}}',
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
)
|
|
162
|
+
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
def fake_tool_check(label: str, command: tuple[str, ...]):
|
|
165
|
+
from codex_workspace_bootstrap.audit import Check
|
|
166
|
+
return Check(label, "pass", "available")
|
|
167
|
+
|
|
168
|
+
monkeypatch.setattr("codex_workspace_bootstrap.audit._tool_check", fake_tool_check)
|
|
169
|
+
|
|
170
|
+
checks = audit_repository(tmp_path)
|
|
171
|
+
by_name = {check.name: check for check in checks}
|
|
172
|
+
|
|
173
|
+
assert by_name["package-manager-evidence"].status == "warn"
|
|
174
|
+
assert "Conflicting" in by_name["package-manager-evidence"].message
|
|
175
|
+
assert "npm" in by_name["package-manager-evidence"].message
|
|
176
|
+
assert "pnpm" in by_name["package-manager-evidence"].message
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_audit_does_not_accept_symlinked_repository_markers(
|
|
180
|
+
tmp_path: Path,
|
|
181
|
+
monkeypatch,
|
|
182
|
+
) -> None:
|
|
183
|
+
readme = tmp_path / "README.md"
|
|
184
|
+
readme.write_text("# demo\n", encoding="utf-8")
|
|
185
|
+
gitignore = tmp_path / ".gitignore"
|
|
186
|
+
gitignore.write_text(".venv/\n", encoding="utf-8")
|
|
187
|
+
agents = tmp_path / "AGENTS.md"
|
|
188
|
+
agents.write_text("# agents\n", encoding="utf-8")
|
|
189
|
+
manifest = tmp_path / "pyproject.toml"
|
|
190
|
+
manifest.write_text("[project]\nname='demo'\n", encoding="utf-8")
|
|
191
|
+
|
|
192
|
+
symlinked = {readme, gitignore, agents, manifest}
|
|
193
|
+
original_is_symlink = Path.is_symlink
|
|
194
|
+
|
|
195
|
+
def fake_is_symlink(path: Path) -> bool:
|
|
196
|
+
if path in symlinked:
|
|
197
|
+
return True
|
|
198
|
+
return original_is_symlink(path)
|
|
199
|
+
|
|
200
|
+
monkeypatch.setattr(Path, "is_symlink", fake_is_symlink)
|
|
201
|
+
|
|
202
|
+
checks = audit_repository(tmp_path)
|
|
203
|
+
by_name = {check.name: check for check in checks}
|
|
204
|
+
|
|
205
|
+
assert by_name["readme"].status == "warn"
|
|
206
|
+
assert by_name["gitignore"].status == "warn"
|
|
207
|
+
assert by_name["agents"].status == "warn"
|
|
208
|
+
assert by_name["project-manifest"].status == "warn"
|
|
@@ -101,7 +101,7 @@ def test_version_flag_reports_package_version(capsys: pytest.CaptureFixture[str]
|
|
|
101
101
|
main(["--version"])
|
|
102
102
|
|
|
103
103
|
assert exc_info.value.code == 0
|
|
104
|
-
assert "codex-workspace-bootstrap 0.6.
|
|
104
|
+
assert "codex-workspace-bootstrap 0.6.1" in capsys.readouterr().out
|
|
105
105
|
|
|
106
106
|
|
|
107
107
|
def test_audit_writes_sarif(tmp_path: Path) -> None:
|
|
@@ -265,3 +265,37 @@ def test_init_agents_refuses_symlink_target_even_with_force(
|
|
|
265
265
|
assert code == 1
|
|
266
266
|
assert "refusing to write through symlink" in capsys.readouterr().err
|
|
267
267
|
assert not target.exists()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_init_agents_ignores_symlinked_project_markers(
|
|
271
|
+
tmp_path: Path,
|
|
272
|
+
monkeypatch,
|
|
273
|
+
) -> None:
|
|
274
|
+
pyproject = tmp_path / "pyproject.toml"
|
|
275
|
+
pyproject.write_text(
|
|
276
|
+
"[project]\nname='demo'\n[tool.pytest.ini_options]\n",
|
|
277
|
+
encoding="utf-8",
|
|
278
|
+
)
|
|
279
|
+
package = tmp_path / "package.json"
|
|
280
|
+
package.write_text(
|
|
281
|
+
'{"packageManager":"pnpm@10","scripts":{"test":"vitest"}}',
|
|
282
|
+
encoding="utf-8",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
symlinked = {pyproject, package}
|
|
286
|
+
original_is_symlink = Path.is_symlink
|
|
287
|
+
|
|
288
|
+
def fake_is_symlink(path: Path) -> bool:
|
|
289
|
+
if path in symlinked:
|
|
290
|
+
return True
|
|
291
|
+
return original_is_symlink(path)
|
|
292
|
+
|
|
293
|
+
monkeypatch.setattr(Path, "is_symlink", fake_is_symlink)
|
|
294
|
+
|
|
295
|
+
code = main(["init-agents", str(tmp_path)])
|
|
296
|
+
|
|
297
|
+
assert code == 0
|
|
298
|
+
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
|
|
299
|
+
assert "No common Python or Node.js manifest detected" in content
|
|
300
|
+
assert "python -m pytest" not in content
|
|
301
|
+
assert "pnpm run test" not in content
|
|
@@ -41,3 +41,20 @@ def test_doctor_has_repository_guidance() -> None:
|
|
|
41
41
|
|
|
42
42
|
assert "init-agents" in findings[0].guidance
|
|
43
43
|
assert ".gitignore" in findings[1].guidance
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_doctor_has_detected_package_manager_guidance() -> None:
|
|
47
|
+
findings = doctor_findings(
|
|
48
|
+
[
|
|
49
|
+
Check("pnpm", "warn", "pnpm command not found"),
|
|
50
|
+
Check(
|
|
51
|
+
"package-manager-evidence",
|
|
52
|
+
"warn",
|
|
53
|
+
"Node.js project detected, but package manager evidence is missing",
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
assert "pnpm" in findings[0].guidance.lower()
|
|
59
|
+
assert "packageManager" in findings[1].guidance
|
|
60
|
+
assert "lockfile" in findings[1].guidance
|
|
@@ -722,3 +722,92 @@ def test_apply_fix_plan_does_not_write_through_symlink_target(tmp_path: Path, mo
|
|
|
722
722
|
|
|
723
723
|
assert applied == []
|
|
724
724
|
assert not target.exists()
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def test_pnpm_filter_command_validates_real_script_name(tmp_path: Path) -> None:
|
|
728
|
+
(tmp_path / "package.json").write_text(
|
|
729
|
+
json.dumps(
|
|
730
|
+
{
|
|
731
|
+
"packageManager": "pnpm@10",
|
|
732
|
+
"scripts": {"test": "vitest"},
|
|
733
|
+
}
|
|
734
|
+
),
|
|
735
|
+
encoding="utf-8",
|
|
736
|
+
)
|
|
737
|
+
(tmp_path / "pnpm-lock.yaml").write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
|
|
738
|
+
(tmp_path / "AGENTS.md").write_text(
|
|
739
|
+
"Run §pnpm --filter web run lint§.\n".replace("§", "`"),
|
|
740
|
+
encoding="utf-8",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
findings = lint_instructions(tmp_path)
|
|
744
|
+
|
|
745
|
+
missing = [item for item in findings if item.kind == "missing-package-script"]
|
|
746
|
+
assert len(missing) == 1
|
|
747
|
+
assert "lint" in missing[0].message
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def test_pnpm_directory_flag_validates_real_script_name(tmp_path: Path) -> None:
|
|
751
|
+
(tmp_path / "package.json").write_text(
|
|
752
|
+
json.dumps(
|
|
753
|
+
{
|
|
754
|
+
"packageManager": "pnpm@10",
|
|
755
|
+
"scripts": {"test": "vitest"},
|
|
756
|
+
}
|
|
757
|
+
),
|
|
758
|
+
encoding="utf-8",
|
|
759
|
+
)
|
|
760
|
+
(tmp_path / "pnpm-lock.yaml").write_text("lockfileVersion: '9.0'\n", encoding="utf-8")
|
|
761
|
+
(tmp_path / "AGENTS.md").write_text(
|
|
762
|
+
"Run §pnpm -C apps/web test§.\n".replace("§", "`"),
|
|
763
|
+
encoding="utf-8",
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
findings = lint_instructions(tmp_path)
|
|
767
|
+
|
|
768
|
+
assert not any(item.kind == "missing-package-script" for item in findings)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def test_npm_workspace_flag_validates_real_script_name(tmp_path: Path) -> None:
|
|
772
|
+
(tmp_path / "package.json").write_text(
|
|
773
|
+
json.dumps(
|
|
774
|
+
{
|
|
775
|
+
"packageManager": "npm@11",
|
|
776
|
+
"scripts": {"test": "vitest"},
|
|
777
|
+
}
|
|
778
|
+
),
|
|
779
|
+
encoding="utf-8",
|
|
780
|
+
)
|
|
781
|
+
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
|
782
|
+
(tmp_path / "AGENTS.md").write_text(
|
|
783
|
+
"Run §npm --workspace app run lint§.\n".replace("§", "`"),
|
|
784
|
+
encoding="utf-8",
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
findings = lint_instructions(tmp_path)
|
|
788
|
+
|
|
789
|
+
missing = [item for item in findings if item.kind == "missing-package-script"]
|
|
790
|
+
assert len(missing) == 1
|
|
791
|
+
assert "lint" in missing[0].message
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def test_yarn_workspace_command_validates_real_script_name(tmp_path: Path) -> None:
|
|
795
|
+
(tmp_path / "package.json").write_text(
|
|
796
|
+
json.dumps(
|
|
797
|
+
{
|
|
798
|
+
"packageManager": "yarn@4",
|
|
799
|
+
"scripts": {"test": "vitest"},
|
|
800
|
+
}
|
|
801
|
+
),
|
|
802
|
+
encoding="utf-8",
|
|
803
|
+
)
|
|
804
|
+
(tmp_path / "yarn.lock").write_text("", encoding="utf-8")
|
|
805
|
+
(tmp_path / "AGENTS.md").write_text(
|
|
806
|
+
"Run §yarn workspace web test§.\n".replace("§", "`"),
|
|
807
|
+
encoding="utf-8",
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
findings = lint_instructions(tmp_path)
|
|
811
|
+
|
|
812
|
+
assert not any(item.kind == "missing-package-script" for item in findings)
|
|
813
|
+
assert not any(item.kind == "package-manager-mismatch" for item in findings)
|
|
@@ -9,6 +9,7 @@ from codex_workspace_bootstrap.preflight import (
|
|
|
9
9
|
build_preflight,
|
|
10
10
|
detect_instruction_signals,
|
|
11
11
|
readiness_state,
|
|
12
|
+
next_actions,
|
|
12
13
|
render_markdown,
|
|
13
14
|
)
|
|
14
15
|
|
|
@@ -153,3 +154,65 @@ def test_conditional_cursor_rule_is_not_repository_wide_baseline() -> None:
|
|
|
153
154
|
)
|
|
154
155
|
|
|
155
156
|
assert readiness_state(checks, [conditional]) == "NEEDS ATTENTION"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_next_actions_recommends_detected_pnpm_toolchain() -> None:
|
|
160
|
+
checks = [
|
|
161
|
+
Check("pnpm", "warn", "pnpm command not found"),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
actions = next_actions(
|
|
165
|
+
checks,
|
|
166
|
+
[],
|
|
167
|
+
["Node.js"],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
assert any(
|
|
171
|
+
item.command == "pnpm --version"
|
|
172
|
+
and "Node.js/pnpm" in item.title
|
|
173
|
+
for item in actions
|
|
174
|
+
)
|
|
175
|
+
assert not any(item.command == "npm --version" for item in actions)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_next_actions_asks_to_confirm_package_manager_when_unproven() -> None:
|
|
179
|
+
checks = [
|
|
180
|
+
Check(
|
|
181
|
+
"package-manager-evidence",
|
|
182
|
+
"warn",
|
|
183
|
+
"Node.js project detected, but package manager evidence is missing",
|
|
184
|
+
),
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
actions = next_actions(
|
|
188
|
+
checks,
|
|
189
|
+
[],
|
|
190
|
+
["Node.js"],
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
assert any(item.title == "Confirm the repository package manager" for item in actions)
|
|
194
|
+
assert not any(item.command and item.command.endswith("--version") for item in actions)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_next_actions_prioritizes_conflicting_package_manager_evidence() -> None:
|
|
198
|
+
checks = [
|
|
199
|
+
Check(
|
|
200
|
+
"package-manager-evidence",
|
|
201
|
+
"warn",
|
|
202
|
+
"Conflicting Node.js package-manager evidence detected: npm, pnpm",
|
|
203
|
+
),
|
|
204
|
+
Check("npm", "pass", "available"),
|
|
205
|
+
Check("pnpm", "pass", "available"),
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
actions = next_actions(
|
|
209
|
+
checks,
|
|
210
|
+
[],
|
|
211
|
+
["Node.js"],
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
assert any(
|
|
215
|
+
item.priority == "P1"
|
|
216
|
+
and item.title == "Resolve conflicting repository package-manager evidence"
|
|
217
|
+
for item in actions
|
|
218
|
+
)
|
|
@@ -94,3 +94,25 @@ def test_preflight_sarif_combines_audit_and_instruction_rules() -> None:
|
|
|
94
94
|
|
|
95
95
|
rule_ids = {item["ruleId"] for item in payload["runs"][0]["results"]}
|
|
96
96
|
assert rule_ids == {"agents", "instruction-missing-scope-metadata"}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_sarif_has_specific_package_manager_help() -> None:
|
|
100
|
+
payload = checks_to_sarif(
|
|
101
|
+
[
|
|
102
|
+
Check("pnpm", "warn", "pnpm command not found"),
|
|
103
|
+
Check(
|
|
104
|
+
"package-manager-evidence",
|
|
105
|
+
"warn",
|
|
106
|
+
"Conflicting Node.js package-manager evidence detected: npm, pnpm",
|
|
107
|
+
),
|
|
108
|
+
]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
rules = {
|
|
112
|
+
rule["id"]: rule
|
|
113
|
+
for rule in payload["runs"][0]["tool"]["driver"]["rules"]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
assert "pnpm" in rules["pnpm"]["help"]["text"].lower()
|
|
117
|
+
assert "packageManager" in rules["package-manager-evidence"]["help"]["text"]
|
|
118
|
+
assert "lockfile" in rules["package-manager-evidence"]["help"]["text"]
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
from pathlib import Path
|
|
2
|
-
import shutil
|
|
3
|
-
import subprocess
|
|
4
|
-
|
|
5
|
-
import pytest
|
|
6
|
-
|
|
7
|
-
from codex_workspace_bootstrap.audit import audit_repository, summary
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def _git(root: Path, *args: str) -> None:
|
|
11
|
-
subprocess.run(
|
|
12
|
-
("git", "-C", str(root), *args),
|
|
13
|
-
check=True,
|
|
14
|
-
capture_output=True,
|
|
15
|
-
text=True,
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_audit_detects_basic_repository_files(tmp_path: Path) -> None:
|
|
20
|
-
(tmp_path / ".git").mkdir()
|
|
21
|
-
(tmp_path / "README.md").write_text("# demo\n", encoding="utf-8")
|
|
22
|
-
(tmp_path / "LICENSE").write_text("MIT\n", encoding="utf-8")
|
|
23
|
-
(tmp_path / ".gitignore").write_text(".env\n", encoding="utf-8")
|
|
24
|
-
(tmp_path / "AGENTS.md").write_text("# agents\n", encoding="utf-8")
|
|
25
|
-
(tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
|
|
26
|
-
|
|
27
|
-
checks = audit_repository(tmp_path)
|
|
28
|
-
by_name = {c.name: c for c in checks}
|
|
29
|
-
|
|
30
|
-
assert by_name["git-repository"].status == "pass"
|
|
31
|
-
assert by_name["readme"].status == "pass"
|
|
32
|
-
assert by_name["license"].status == "pass"
|
|
33
|
-
assert by_name["gitignore"].status == "pass"
|
|
34
|
-
assert by_name["agents"].status == "pass"
|
|
35
|
-
assert by_name["project-manifest"].status == "pass"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_audit_warns_on_secret_risk_filename(tmp_path: Path) -> None:
|
|
39
|
-
(tmp_path / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
40
|
-
checks = audit_repository(tmp_path)
|
|
41
|
-
by_name = {c.name: c for c in checks}
|
|
42
|
-
assert by_name["secret-risk-files"].status == "warn"
|
|
43
|
-
assert by_name["secret-risk-files"].blocking is False
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
@pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
|
|
47
|
-
def test_tracked_secret_risk_file_is_blocking(tmp_path: Path) -> None:
|
|
48
|
-
_git(tmp_path, "init")
|
|
49
|
-
(tmp_path / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
50
|
-
_git(tmp_path, "add", "-f", ".env")
|
|
51
|
-
|
|
52
|
-
checks = audit_repository(tmp_path)
|
|
53
|
-
by_name = {c.name: c for c in checks}
|
|
54
|
-
|
|
55
|
-
assert by_name["secret-risk-files"].status == "warn"
|
|
56
|
-
assert by_name["secret-risk-files"].blocking is True
|
|
57
|
-
assert "tracked:" in by_name["secret-risk-files"].message
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
|
|
61
|
-
def test_ignored_secret_risk_file_is_not_blocking(tmp_path: Path) -> None:
|
|
62
|
-
_git(tmp_path, "init")
|
|
63
|
-
(tmp_path / ".gitignore").write_text(".env\n", encoding="utf-8")
|
|
64
|
-
(tmp_path / ".env").write_text("EXAMPLE=not-a-secret\n", encoding="utf-8")
|
|
65
|
-
|
|
66
|
-
checks = audit_repository(tmp_path)
|
|
67
|
-
by_name = {c.name: c for c in checks}
|
|
68
|
-
|
|
69
|
-
assert by_name["secret-risk-files"].status == "warn"
|
|
70
|
-
assert by_name["secret-risk-files"].blocking is False
|
|
71
|
-
assert "ignored:" in by_name["secret-risk-files"].message
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def test_summary_counts_statuses(tmp_path: Path) -> None:
|
|
75
|
-
checks = audit_repository(tmp_path)
|
|
76
|
-
totals = summary(checks)
|
|
77
|
-
assert totals["passed"] + totals["warnings"] == len(checks)
|
|
78
|
-
assert totals["blocking"] == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|