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.
Files changed (29) hide show
  1. {codex_workspace_bootstrap-0.6.0/src/codex_workspace_bootstrap.egg-info → codex_workspace_bootstrap-0.6.1}/PKG-INFO +5 -5
  2. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/README.md +4 -4
  3. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/pyproject.toml +1 -1
  4. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/__init__.py +1 -1
  5. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/agents.py +10 -6
  6. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/audit.py +91 -14
  7. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/doctor.py +5 -1
  8. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/instructions.py +65 -29
  9. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/preflight.py +28 -6
  10. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/sarif.py +4 -0
  11. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1/src/codex_workspace_bootstrap.egg-info}/PKG-INFO +5 -5
  12. codex_workspace_bootstrap-0.6.1/tests/test_audit.py +208 -0
  13. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_cli.py +35 -1
  14. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_doctor.py +17 -0
  15. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_instruction_integrity.py +89 -0
  16. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_preflight.py +63 -0
  17. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_sarif.py +22 -0
  18. codex_workspace_bootstrap-0.6.0/tests/test_audit.py +0 -78
  19. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/LICENSE +0 -0
  20. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/setup.cfg +0 -0
  21. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/__main__.py +0 -0
  22. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/cli.py +0 -0
  23. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/fixes.py +0 -0
  24. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/SOURCES.txt +0 -0
  25. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/dependency_links.txt +0 -0
  26. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/entry_points.txt +0 -0
  27. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/top_level.txt +0 -0
  28. {codex_workspace_bootstrap-0.6.0 → codex_workspace_bootstrap-0.6.1}/tests/test_public_repo_patterns.py +0 -0
  29. {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.0
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 / npm / PowerShell / WSL / Codex signals
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.0
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 read through symbolic links.
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.0/codex_workspace_bootstrap-0.6.0-py3-none-any.whl"
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 / npm / PowerShell / WSL / Codex signals
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.0
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 read through symbolic links.
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.0/codex_workspace_bootstrap-0.6.0-py3-none-any.whl"
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.0"
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"
@@ -1,3 +1,3 @@
1
1
  """codex-workspace-bootstrap package."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.1"
@@ -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).exists() for marker in PYTHON_MARKERS):
27
+ if any(_regular_file(root / marker) for marker in PYTHON_MARKERS):
24
28
  signals.append("Python")
25
- if (root / NODE_MARKER).exists():
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 _node_package_managers(root: Path) -> set[str]:
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").exists()
158
- or (root / "conftest.py").exists()
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 = _node_package_managers(root)
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 = {".git", ".venv", "venv", "node_modules", "__pycache__"}
77
- for path in root.rglob("*"):
78
- if any(part in excluded for part in path.parts):
79
- continue
80
- if path.is_file():
81
- yield path
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((p for p in root.iterdir() if p.is_file() and p.name.lower().startswith("readme")), None)
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((p for p in root.iterdir() if p.is_file() and p.name.lower().startswith("license")), None)
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" if (root / ".gitignore").exists() else "warn",
157
- ".gitignore detected" if (root / ".gitignore").exists() else ".gitignore not found",
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" if (root / "AGENTS.md").exists() else "warn",
165
- "AGENTS.md detected" if (root / "AGENTS.md").exists() else "AGENTS.md not found; run init-agents",
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 = [name for name in MANIFESTS if (root / name).exists()]
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 with the active Node.js installation.",
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
- def _script_for_command(command: str) -> tuple[str, str] | None:
612
- match = _SCRIPT_COMMAND.match(command.strip())
613
- if not match:
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
- manager = (match.group(1) or "yarn").lower()
616
- script = match.group(2) or match.group(3)
617
- if not script or script in {"install", "ci", "exec", "dlx"}:
630
+
631
+ manager = tokens[0].lower()
632
+ if manager not in {"npm", "pnpm", "yarn", "bun"}:
618
633
  return None
619
634
 
620
- # Be conservative with workspace/filter/prefix style commands. The first
621
- # token after the package manager may be an option rather than a script,
622
- # for example:
623
- # pnpm --filter web test
624
- # npm --workspace app run test
625
- # pnpm -C apps/web test
626
- #
627
- # Treating that option as a script creates false missing-script findings.
628
- # Until full option parsing is implemented, skip script validation for
629
- # commands whose apparent script begins with a dash.
630
- if script.startswith("-"):
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
- npm = _check_by_name(checks, "npm")
134
- if npm is not None and npm.status != "pass":
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
- "P1",
138
- "Restore the Node.js/npm toolchain required by this repository",
139
- command="npm --version",
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.0
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 / npm / PowerShell / WSL / Codex signals
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.0
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 read through symbolic links.
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.0/codex_workspace_bootstrap-0.6.0-py3-none-any.whl"
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.0" in capsys.readouterr().out
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