codex-workspace-bootstrap 0.5.2__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 (30) hide show
  1. {codex_workspace_bootstrap-0.5.2/src/codex_workspace_bootstrap.egg-info → codex_workspace_bootstrap-0.6.1}/PKG-INFO +11 -9
  2. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/README.md +10 -8
  3. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/pyproject.toml +1 -1
  4. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/__init__.py +1 -1
  5. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/agents.py +101 -22
  6. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/audit.py +91 -14
  7. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/cli.py +3 -0
  8. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/doctor.py +5 -1
  9. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/fixes.py +1 -1
  10. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/instructions.py +312 -72
  11. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/preflight.py +28 -6
  12. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/sarif.py +4 -0
  13. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1/src/codex_workspace_bootstrap.egg-info}/PKG-INFO +11 -9
  14. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/SOURCES.txt +1 -0
  15. codex_workspace_bootstrap-0.6.1/tests/test_audit.py +208 -0
  16. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/tests/test_cli.py +112 -3
  17. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/tests/test_doctor.py +17 -0
  18. codex_workspace_bootstrap-0.6.1/tests/test_instruction_integrity.py +813 -0
  19. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/tests/test_preflight.py +63 -0
  20. codex_workspace_bootstrap-0.6.1/tests/test_release_consistency.py +49 -0
  21. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/tests/test_sarif.py +22 -0
  22. codex_workspace_bootstrap-0.5.2/tests/test_audit.py +0 -78
  23. codex_workspace_bootstrap-0.5.2/tests/test_instruction_integrity.py +0 -381
  24. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/LICENSE +0 -0
  25. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/setup.cfg +0 -0
  26. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap/__main__.py +0 -0
  27. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/dependency_links.txt +0 -0
  28. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/entry_points.txt +0 -0
  29. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/src/codex_workspace_bootstrap.egg-info/top_level.txt +0 -0
  30. {codex_workspace_bootstrap-0.5.2 → codex_workspace_bootstrap-0.6.1}/tests/test_public_repo_patterns.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-workspace-bootstrap
3
- Version: 0.5.2
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
 
@@ -142,7 +142,7 @@ It does not claim these files are correct merely because they exist. It tells yo
142
142
 
143
143
  ### Cross-agent instruction integrity
144
144
 
145
- When multiple AI instruction files exist, `cwb` reads executable-looking commands and checks them against repository evidence. It understands nested `AGENTS.md` / `AGENTS.override.md`, nested Cursor `.cursor/rules`, and common path-specific frontmatter such as Copilot `applyTo` and rule `globs`. It can flag:
145
+ When multiple AI instruction files exist, `cwb` reads executable-looking commands and checks them against repository evidence. It understands nested `AGENTS.md` / `AGENTS.override.md`, nested `CLAUDE.md`, Gemini CLI hierarchical context files (including project `context.fileName` overrides), nested Cursor `.cursor/rules`, and common path-specific frontmatter such as Copilot `applyTo` and rule `globs`. It can flag:
146
146
 
147
147
  - package-manager mismatches against `packageManager` and lockfiles;
148
148
  - cross-agent package-manager drift;
@@ -248,18 +248,18 @@ cwb doctor .
248
248
  cwb init-agents .
249
249
  ```
250
250
 
251
- Generation is evidence-based: the tool cross-checks project manifests, package scripts, pytest configuration, and README commands. Plausible but unconfirmed commands are separated for maintainer review. Existing `AGENTS.md` files are never overwritten unless `--force` is explicit.
251
+ Generation is evidence-based: the tool cross-checks project manifests, package-manager evidence, package scripts, pytest configuration, and README commands. Plausible but unconfirmed commands are separated for maintainer review. Existing `AGENTS.md` files are never overwritten unless `--force` is explicit.
252
252
 
253
253
  ## GitHub Action
254
254
 
255
255
  The reusable Action is published on GitHub Marketplace.
256
256
 
257
257
  ```yaml
258
- - uses: actions/checkout@v7
259
- - uses: actions/setup-python@v7
258
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
259
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
260
260
  with:
261
261
  python-version: "3.13"
262
- - uses: kohli217/codex-workspace-bootstrap@v0.5.2
262
+ - uses: kohli217/codex-workspace-bootstrap@v0.6.1
263
263
  with:
264
264
  path: .
265
265
  strict: "true"
@@ -291,7 +291,9 @@ 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
- - Existing `AGENTS.md` files are protected unless overwrite is explicit.
294
+ - Repository instruction/configuration and project-marker inputs are not trusted through symbolic links.
295
+ - `init-agents` and automatic fixes refuse symlinked `AGENTS.md` targets rather than writing through them.
296
+ - Existing regular `AGENTS.md` files are protected unless overwrite is explicit.
295
297
  - A passing preflight is evidence about the checks performed, **not a security guarantee**.
296
298
 
297
299
  See [SECURITY.md](SECURITY.md).
@@ -323,7 +325,7 @@ py -m pip install codex-workspace-bootstrap
323
325
  Pinned GitHub release artifact:
324
326
 
325
327
  ```powershell
326
- py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.5.2/codex_workspace_bootstrap-0.5.2-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"
327
329
  ```
328
330
 
329
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
 
@@ -114,7 +114,7 @@ It does not claim these files are correct merely because they exist. It tells yo
114
114
 
115
115
  ### Cross-agent instruction integrity
116
116
 
117
- When multiple AI instruction files exist, `cwb` reads executable-looking commands and checks them against repository evidence. It understands nested `AGENTS.md` / `AGENTS.override.md`, nested Cursor `.cursor/rules`, and common path-specific frontmatter such as Copilot `applyTo` and rule `globs`. It can flag:
117
+ When multiple AI instruction files exist, `cwb` reads executable-looking commands and checks them against repository evidence. It understands nested `AGENTS.md` / `AGENTS.override.md`, nested `CLAUDE.md`, Gemini CLI hierarchical context files (including project `context.fileName` overrides), nested Cursor `.cursor/rules`, and common path-specific frontmatter such as Copilot `applyTo` and rule `globs`. It can flag:
118
118
 
119
119
  - package-manager mismatches against `packageManager` and lockfiles;
120
120
  - cross-agent package-manager drift;
@@ -220,18 +220,18 @@ cwb doctor .
220
220
  cwb init-agents .
221
221
  ```
222
222
 
223
- Generation is evidence-based: the tool cross-checks project manifests, package scripts, pytest configuration, and README commands. Plausible but unconfirmed commands are separated for maintainer review. Existing `AGENTS.md` files are never overwritten unless `--force` is explicit.
223
+ Generation is evidence-based: the tool cross-checks project manifests, package-manager evidence, package scripts, pytest configuration, and README commands. Plausible but unconfirmed commands are separated for maintainer review. Existing `AGENTS.md` files are never overwritten unless `--force` is explicit.
224
224
 
225
225
  ## GitHub Action
226
226
 
227
227
  The reusable Action is published on GitHub Marketplace.
228
228
 
229
229
  ```yaml
230
- - uses: actions/checkout@v7
231
- - uses: actions/setup-python@v7
230
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
231
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
232
232
  with:
233
233
  python-version: "3.13"
234
- - uses: kohli217/codex-workspace-bootstrap@v0.5.2
234
+ - uses: kohli217/codex-workspace-bootstrap@v0.6.1
235
235
  with:
236
236
  path: .
237
237
  strict: "true"
@@ -263,7 +263,9 @@ 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
- - Existing `AGENTS.md` files are protected unless overwrite is explicit.
266
+ - Repository instruction/configuration and project-marker inputs are not trusted through symbolic links.
267
+ - `init-agents` and automatic fixes refuse symlinked `AGENTS.md` targets rather than writing through them.
268
+ - Existing regular `AGENTS.md` files are protected unless overwrite is explicit.
267
269
  - A passing preflight is evidence about the checks performed, **not a security guarantee**.
268
270
 
269
271
  See [SECURITY.md](SECURITY.md).
@@ -295,7 +297,7 @@ py -m pip install codex-workspace-bootstrap
295
297
  Pinned GitHub release artifact:
296
298
 
297
299
  ```powershell
298
- py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.5.2/codex_workspace_bootstrap-0.5.2-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"
299
301
  ```
300
302
 
301
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.5.2"
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.5.2"
3
+ __version__ = "0.6.1"
@@ -18,18 +18,22 @@ 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
 
29
33
 
30
34
  def _node_script_names(root: Path) -> set[str]:
31
35
  package_json = root / NODE_MARKER
32
- if not package_json.exists():
36
+ if not package_json.is_file() or package_json.is_symlink():
33
37
  return set()
34
38
 
35
39
  try:
@@ -44,10 +48,48 @@ 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
 
51
+ def detect_node_package_managers(root: Path) -> set[str]:
52
+ managers: set[str] = set()
53
+ package_json = root / NODE_MARKER
54
+
55
+ if package_json.is_file() and not package_json.is_symlink():
56
+ try:
57
+ data = json.loads(package_json.read_text(encoding="utf-8"))
58
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
59
+ data = {}
60
+ if isinstance(data, dict):
61
+ declared = data.get("packageManager")
62
+ if isinstance(declared, str) and declared:
63
+ manager = declared.split("@", 1)[0].strip().lower()
64
+ if manager in {"npm", "pnpm", "yarn", "bun"}:
65
+ managers.add(manager)
66
+
67
+ lockfiles = {
68
+ "package-lock.json": "npm",
69
+ "npm-shrinkwrap.json": "npm",
70
+ "pnpm-lock.yaml": "pnpm",
71
+ "yarn.lock": "yarn",
72
+ "bun.lock": "bun",
73
+ "bun.lockb": "bun",
74
+ }
75
+ for filename, manager in lockfiles.items():
76
+ candidate = root / filename
77
+ if candidate.is_file() and not candidate.is_symlink():
78
+ managers.add(manager)
79
+
80
+ return managers
81
+
82
+
83
+ def _package_script_command(manager: str, script: str) -> str:
84
+ if manager == "npm" and script == "test":
85
+ return "npm test"
86
+ return f"{manager} run {script}"
87
+
88
+
47
89
  def _readme_text(root: Path) -> str:
48
90
  for name in README_NAMES:
49
91
  path = root / name
50
- if path.is_file():
92
+ if path.is_file() and not path.is_symlink():
51
93
  try:
52
94
  return path.read_text(encoding="utf-8")
53
95
  except (OSError, UnicodeDecodeError):
@@ -64,10 +106,10 @@ def _documented_commands(root: Path) -> set[str]:
64
106
  "python -m pytest",
65
107
  "pytest",
66
108
  "python -m unittest",
67
- "npm test",
68
- "npm run lint",
69
- "npm run build",
70
109
  }
110
+ for manager in ("npm", "pnpm", "yarn", "bun"):
111
+ for script in ("test", "lint", "build"):
112
+ candidates.add(_package_script_command(manager, script))
71
113
  found: set[str] = set()
72
114
  for command in candidates:
73
115
  pattern = rf"(?m)(^|[\s$>]){re.escape(command)}(?=$|\s)"
@@ -78,7 +120,7 @@ def _documented_commands(root: Path) -> set[str]:
78
120
 
79
121
  def _pyproject_has_pytest(root: Path) -> bool:
80
122
  path = root / "pyproject.toml"
81
- if not path.is_file():
123
+ if not path.is_file() or path.is_symlink():
82
124
  return False
83
125
 
84
126
  try:
@@ -116,8 +158,8 @@ def validation_plan(root: Path) -> list[ValidationCommand]:
116
158
  if "Python" in signals:
117
159
  pytest_evidence = (
118
160
  _pyproject_has_pytest(root)
119
- or (root / "pytest.ini").exists()
120
- or (root / "conftest.py").exists()
161
+ or _regular_file(root / "pytest.ini")
162
+ or _regular_file(root / "conftest.py")
121
163
  )
122
164
  if "python -m pytest" in documented or "pytest" in documented:
123
165
  plan.append(ValidationCommand("python -m pytest", "documented in README"))
@@ -136,19 +178,56 @@ def validation_plan(root: Path) -> list[ValidationCommand]:
136
178
 
137
179
  if "Node.js" in signals:
138
180
  scripts = _node_script_names(root)
139
- if "test" in scripts:
140
- plan.append(ValidationCommand("npm test", "package.json defines scripts.test"))
141
- if "lint" in scripts:
142
- plan.append(ValidationCommand("npm run lint", "package.json defines scripts.lint"))
143
- if "build" in scripts and "npm run build" in documented:
144
- plan.append(ValidationCommand("npm run build", "package.json defines scripts.build and README documents it"))
145
- if not {"test", "lint"} & scripts:
146
- plan.append(
147
- ValidationCommand(
148
- "npm install --ignore-scripts --package-lock-only --dry-run",
149
- "Node.js project detected; no test or lint script confirmed",
181
+ managers = detect_node_package_managers(root)
182
+ manager = next(iter(managers)) if len(managers) == 1 else None
183
+
184
+ if manager:
185
+ if "test" in scripts:
186
+ plan.append(
187
+ ValidationCommand(
188
+ _package_script_command(manager, "test"),
189
+ f"package.json defines scripts.test and repository evidence selects {manager}",
190
+ )
191
+ )
192
+ if "lint" in scripts:
193
+ plan.append(
194
+ ValidationCommand(
195
+ _package_script_command(manager, "lint"),
196
+ f"package.json defines scripts.lint and repository evidence selects {manager}",
197
+ )
198
+ )
199
+ build_command = _package_script_command(manager, "build")
200
+ if "build" in scripts and build_command in documented:
201
+ plan.append(
202
+ ValidationCommand(
203
+ build_command,
204
+ f"package.json defines scripts.build, repository evidence selects {manager}, and README documents it",
205
+ )
206
+ )
207
+ elif len(managers) > 1:
208
+ # Conflicting package-manager evidence is handled by preflight.
209
+ # Do not manufacture a manager-specific validation command here.
210
+ pass
211
+ else:
212
+ # package.json alone does not prove npm. Preserve the historical
213
+ # npm suggestions only as review-required hints until the project
214
+ # supplies a lockfile, packageManager field, or documented command.
215
+ if "test" in scripts:
216
+ plan.append(
217
+ ValidationCommand(
218
+ "npm test",
219
+ "package.json defines scripts.test, but the package manager is not confirmed",
220
+ review_required=True,
221
+ )
222
+ )
223
+ if "lint" in scripts:
224
+ plan.append(
225
+ ValidationCommand(
226
+ "npm run lint",
227
+ "package.json defines scripts.lint, but the package manager is not confirmed",
228
+ review_required=True,
229
+ )
150
230
  )
151
- )
152
231
 
153
232
  plan.extend(
154
233
  [
@@ -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()
@@ -270,6 +270,9 @@ def _run_init_agents(path: str, force: bool) -> int:
270
270
  return 2
271
271
 
272
272
  target = root / "AGENTS.md"
273
+ if target.is_symlink():
274
+ print(f"error: refusing to write through symlink: {target}", file=sys.stderr)
275
+ return 1
273
276
  if target.exists() and not force:
274
277
  print(f"error: {target} already exists; use --force to overwrite", file=sys.stderr)
275
278
  return 1
@@ -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.",
@@ -64,7 +64,7 @@ def apply_fix_plan(root: Path, plan: list[FixPlanItem]) -> list[str]:
64
64
  continue
65
65
  if item.kind == "create-agents":
66
66
  target = root / "AGENTS.md"
67
- if target.exists():
67
+ if target.exists() or target.is_symlink():
68
68
  continue
69
69
  target.write_text(generate_agents(root), encoding="utf-8")
70
70
  applied.append(str(target))