codex-workspace-bootstrap 0.6.1__tar.gz → 0.7.0__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 (28) hide show
  1. {codex_workspace_bootstrap-0.6.1/src/codex_workspace_bootstrap.egg-info → codex_workspace_bootstrap-0.7.0}/PKG-INFO +15 -6
  2. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/README.md +14 -5
  3. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/pyproject.toml +1 -1
  4. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/__init__.py +1 -1
  5. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/agents.py +22 -1
  6. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/audit.py +79 -7
  7. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/cli.py +9 -0
  8. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/instructions.py +100 -7
  9. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/preflight.py +17 -0
  10. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0/src/codex_workspace_bootstrap.egg-info}/PKG-INFO +15 -6
  11. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_audit.py +117 -1
  12. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_cli.py +64 -3
  13. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_instruction_integrity.py +22 -0
  14. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_preflight.py +79 -0
  15. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_public_repo_patterns.py +25 -1
  16. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_release_consistency.py +36 -0
  17. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/LICENSE +0 -0
  18. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/setup.cfg +0 -0
  19. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/__main__.py +0 -0
  20. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/doctor.py +0 -0
  21. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/fixes.py +0 -0
  22. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap/sarif.py +0 -0
  23. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap.egg-info/SOURCES.txt +0 -0
  24. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap.egg-info/dependency_links.txt +0 -0
  25. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap.egg-info/entry_points.txt +0 -0
  26. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/src/codex_workspace_bootstrap.egg-info/top_level.txt +0 -0
  27. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_doctor.py +0 -0
  28. {codex_workspace_bootstrap-0.6.1 → codex_workspace_bootstrap-0.7.0}/tests/test_sarif.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-workspace-bootstrap
3
- Version: 0.6.1
3
+ Version: 0.7.0
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
@@ -123,7 +123,7 @@ The goal is not a vanity score. The result is one of:
123
123
  - README
124
124
  - license
125
125
  - `.gitignore`
126
- - common project manifests
126
+ - common project manifests, including Python, Node.js, Go, Rust, JVM, and .NET roots
127
127
  - local Git / Python / Node.js / repository-selected package manager / PowerShell / WSL / Codex signals
128
128
 
129
129
  ### AI instruction coverage
@@ -153,7 +153,7 @@ The lint is intentionally conservative: different files may contain additional c
153
153
 
154
154
  ### Risk signals
155
155
 
156
- The audit warns about common secret-bearing filenames without printing their contents. When Git is available, it distinguishes **tracked**, **ignored**, and **untracked/unknown** candidates. Tracked risky filenames can become blocking findings in strict mode.
156
+ The audit warns about common secret-bearing filenames without printing their contents, including environment-specific `.env.*` files while excluding common template names such as `.env.example` and `.env.sample`. When Git is available, it distinguishes **tracked**, **ignored**, and **untracked/unknown** candidates. Tracked risky filenames can become blocking findings in strict mode.
157
157
 
158
158
  This is intentionally a lightweight preflight check, not a replacement for deep scanners such as Gitleaks or Trivy.
159
159
 
@@ -211,6 +211,14 @@ Fail CI on any instruction-integrity finding:
211
211
  cwb preflight . --fail-on-integrity
212
212
  ```
213
213
 
214
+ Require the full preflight state to be `READY`:
215
+
216
+ ```powershell
217
+ cwb preflight . --require-ready
218
+ ```
219
+
220
+ Use `--require-ready` when CI should reject both `NEEDS ATTENTION` and `BLOCKED`, including missing repository-wide AI instructions or essential repository markers.
221
+
214
222
  ### Detailed audit
215
223
 
216
224
  ```powershell
@@ -248,7 +256,7 @@ cwb doctor .
248
256
  cwb init-agents .
249
257
  ```
250
258
 
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.
259
+ Generation is evidence-based: the tool identifies common Python, Node.js, Go, Rust, JVM, and .NET project roots, then only emits validation commands that have direct repository evidence. Python/Node-specific validation is cross-checked against 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
260
 
253
261
  ## GitHub Action
254
262
 
@@ -259,11 +267,12 @@ The reusable Action is published on GitHub Marketplace.
259
267
  - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
260
268
  with:
261
269
  python-version: "3.13"
262
- - uses: kohli217/codex-workspace-bootstrap@v0.6.1
270
+ - uses: kohli217/codex-workspace-bootstrap@v0.7.0
263
271
  with:
264
272
  path: .
265
273
  strict: "true"
266
274
  fail_on_integrity: "true"
275
+ require_ready: "true"
267
276
  ```
268
277
 
269
278
  The Action adds the preflight Markdown report to the **GitHub Actions job summary**, so maintainers get a readable readiness snapshot without digging through raw logs. Optional SARIF output contains both repository-audit and instruction-integrity findings and can be uploaded to GitHub Code Scanning.
@@ -325,7 +334,7 @@ py -m pip install codex-workspace-bootstrap
325
334
  Pinned GitHub release artifact:
326
335
 
327
336
  ```powershell
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"
337
+ py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.7.0/codex_workspace_bootstrap-0.7.0-py3-none-any.whl"
329
338
  ```
330
339
 
331
340
  ## Development
@@ -95,7 +95,7 @@ The goal is not a vanity score. The result is one of:
95
95
  - README
96
96
  - license
97
97
  - `.gitignore`
98
- - common project manifests
98
+ - common project manifests, including Python, Node.js, Go, Rust, JVM, and .NET roots
99
99
  - local Git / Python / Node.js / repository-selected package manager / PowerShell / WSL / Codex signals
100
100
 
101
101
  ### AI instruction coverage
@@ -125,7 +125,7 @@ The lint is intentionally conservative: different files may contain additional c
125
125
 
126
126
  ### Risk signals
127
127
 
128
- The audit warns about common secret-bearing filenames without printing their contents. When Git is available, it distinguishes **tracked**, **ignored**, and **untracked/unknown** candidates. Tracked risky filenames can become blocking findings in strict mode.
128
+ The audit warns about common secret-bearing filenames without printing their contents, including environment-specific `.env.*` files while excluding common template names such as `.env.example` and `.env.sample`. When Git is available, it distinguishes **tracked**, **ignored**, and **untracked/unknown** candidates. Tracked risky filenames can become blocking findings in strict mode.
129
129
 
130
130
  This is intentionally a lightweight preflight check, not a replacement for deep scanners such as Gitleaks or Trivy.
131
131
 
@@ -183,6 +183,14 @@ Fail CI on any instruction-integrity finding:
183
183
  cwb preflight . --fail-on-integrity
184
184
  ```
185
185
 
186
+ Require the full preflight state to be `READY`:
187
+
188
+ ```powershell
189
+ cwb preflight . --require-ready
190
+ ```
191
+
192
+ Use `--require-ready` when CI should reject both `NEEDS ATTENTION` and `BLOCKED`, including missing repository-wide AI instructions or essential repository markers.
193
+
186
194
  ### Detailed audit
187
195
 
188
196
  ```powershell
@@ -220,7 +228,7 @@ cwb doctor .
220
228
  cwb init-agents .
221
229
  ```
222
230
 
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.
231
+ Generation is evidence-based: the tool identifies common Python, Node.js, Go, Rust, JVM, and .NET project roots, then only emits validation commands that have direct repository evidence. Python/Node-specific validation is cross-checked against 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
232
 
225
233
  ## GitHub Action
226
234
 
@@ -231,11 +239,12 @@ The reusable Action is published on GitHub Marketplace.
231
239
  - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
232
240
  with:
233
241
  python-version: "3.13"
234
- - uses: kohli217/codex-workspace-bootstrap@v0.6.1
242
+ - uses: kohli217/codex-workspace-bootstrap@v0.7.0
235
243
  with:
236
244
  path: .
237
245
  strict: "true"
238
246
  fail_on_integrity: "true"
247
+ require_ready: "true"
239
248
  ```
240
249
 
241
250
  The Action adds the preflight Markdown report to the **GitHub Actions job summary**, so maintainers get a readable readiness snapshot without digging through raw logs. Optional SARIF output contains both repository-audit and instruction-integrity findings and can be uploaded to GitHub Code Scanning.
@@ -297,7 +306,7 @@ py -m pip install codex-workspace-bootstrap
297
306
  Pinned GitHub release artifact:
298
307
 
299
308
  ```powershell
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"
309
+ py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.7.0/codex_workspace_bootstrap-0.7.0-py3-none-any.whl"
301
310
  ```
302
311
 
303
312
  ## 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.1"
7
+ version = "0.7.0"
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.1"
3
+ __version__ = "0.7.0"
@@ -8,6 +8,16 @@ import re
8
8
 
9
9
  PYTHON_MARKERS = ("pyproject.toml", "requirements.txt", "setup.py", "setup.cfg")
10
10
  NODE_MARKER = "package.json"
11
+ GO_MARKERS = ("go.mod",)
12
+ RUST_MARKERS = ("Cargo.toml",)
13
+ JVM_MARKERS = (
14
+ "pom.xml",
15
+ "build.gradle",
16
+ "build.gradle.kts",
17
+ "settings.gradle",
18
+ "settings.gradle.kts",
19
+ )
20
+ DOTNET_SUFFIXES = (".sln", ".csproj", ".fsproj", ".vbproj")
11
21
  README_NAMES = ("README.md", "README.rst", "README.txt", "README")
12
22
 
13
23
 
@@ -28,6 +38,17 @@ def detect_project_signals(root: Path) -> list[str]:
28
38
  signals.append("Python")
29
39
  if _regular_file(root / NODE_MARKER):
30
40
  signals.append("Node.js")
41
+ if any(_regular_file(root / marker) for marker in GO_MARKERS):
42
+ signals.append("Go")
43
+ if any(_regular_file(root / marker) for marker in RUST_MARKERS):
44
+ signals.append("Rust")
45
+ if any(_regular_file(root / marker) for marker in JVM_MARKERS):
46
+ signals.append("JVM")
47
+ if any(
48
+ _regular_file(path) and path.suffix.lower() in DOTNET_SUFFIXES
49
+ for path in root.iterdir()
50
+ ):
51
+ signals.append(".NET")
31
52
  return signals
32
53
 
33
54
 
@@ -248,7 +269,7 @@ def validation_commands(root: Path) -> list[str]:
248
269
 
249
270
  def generate_agents(root: Path) -> str:
250
271
  signals = detect_project_signals(root)
251
- signal_text = ", ".join(signals) if signals else "No common Python or Node.js manifest detected"
272
+ signal_text = ", ".join(signals) if signals else "No supported project manifest detected"
252
273
  plan = validation_plan(root)
253
274
 
254
275
  confirmed = [item for item in plan if not item.review_required]
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  import os
6
6
  import shutil
7
7
  import subprocess
8
+ import sys
8
9
  from typing import Iterable
9
10
 
10
11
  from .agents import detect_node_package_managers
@@ -38,6 +39,9 @@ MANIFESTS = (
38
39
  "Cargo.toml",
39
40
  "pom.xml",
40
41
  "build.gradle",
42
+ "build.gradle.kts",
43
+ "settings.gradle",
44
+ "settings.gradle.kts",
41
45
  )
42
46
 
43
47
  RISK_FILENAMES = {
@@ -50,6 +54,13 @@ RISK_FILENAMES = {
50
54
  }
51
55
 
52
56
  RISK_SUFFIXES = (".pem", ".p12", ".pfx", ".key")
57
+ SAFE_ENV_TEMPLATE_NAMES = {
58
+ ".env.example",
59
+ ".env.sample",
60
+ ".env.template",
61
+ ".env.dist",
62
+ ".env.defaults",
63
+ }
53
64
 
54
65
  NODE_PACKAGE_MANAGER_COMMANDS: dict[str, tuple[str, ...]] = {
55
66
  "npm": ("npm", "--version"),
@@ -108,6 +119,28 @@ def _iter_project_files(root: Path) -> Iterable[Path]:
108
119
  yield current_path / filename
109
120
 
110
121
 
122
+ def _git_tracked_files(root: Path) -> set[str] | None:
123
+ if shutil.which("git") is None:
124
+ return None
125
+ try:
126
+ result = subprocess.run(
127
+ ("git", "-C", str(root), "ls-files", "-z"),
128
+ capture_output=True,
129
+ text=False,
130
+ timeout=10,
131
+ check=False,
132
+ )
133
+ if result.returncode != 0:
134
+ return None
135
+ return {
136
+ item.decode(sys.getfilesystemencoding(), errors="surrogateescape")
137
+ for item in result.stdout.split(b"\0")
138
+ if item
139
+ }
140
+ except (OSError, subprocess.SubprocessError):
141
+ return None
142
+
143
+
111
144
  def _git_matches(root: Path, args: tuple[str, ...]) -> bool:
112
145
  if shutil.which("git") is None:
113
146
  return False
@@ -124,13 +157,19 @@ def _git_matches(root: Path, args: tuple[str, ...]) -> bool:
124
157
  return False
125
158
 
126
159
 
127
- def _classify_risky_paths(root: Path, risky: list[str]) -> tuple[list[str], list[str], list[str]]:
160
+ def _classify_risky_paths(
161
+ root: Path,
162
+ risky: list[str],
163
+ tracked_files: set[str] | None = None,
164
+ ) -> tuple[list[str], list[str], list[str]]:
128
165
  tracked: list[str] = []
129
166
  ignored: list[str] = []
130
167
  untracked: list[str] = []
131
168
 
132
169
  for relative in risky:
133
- if _git_matches(root, ("ls-files", "--error-unmatch", "--", relative)):
170
+ if tracked_files is not None and relative in tracked_files:
171
+ tracked.append(relative)
172
+ elif tracked_files is None and _git_matches(root, ("ls-files", "--error-unmatch", "--", relative)):
134
173
  tracked.append(relative)
135
174
  elif _git_matches(root, ("check-ignore", "--quiet", "--", relative)):
136
175
  ignored.append(relative)
@@ -140,6 +179,15 @@ def _classify_risky_paths(root: Path, risky: list[str]) -> tuple[list[str], list
140
179
  return tracked, ignored, untracked
141
180
 
142
181
 
182
+ def _is_risky_filename(name: str) -> bool:
183
+ lowered = name.lower()
184
+ if lowered in RISK_FILENAMES or lowered.endswith(RISK_SUFFIXES):
185
+ return True
186
+ if lowered.startswith(".env.") and lowered not in SAFE_ENV_TEMPLATE_NAMES:
187
+ return True
188
+ return False
189
+
190
+
143
191
  def _preview(paths: list[str], limit: int = 8) -> str:
144
192
  visible = ", ".join(sorted(paths)[:limit])
145
193
  suffix = "" if len(paths) <= limit else f" (+{len(paths) - limit} more)"
@@ -220,6 +268,15 @@ def audit_repository(root: Path) -> list[Check]:
220
268
  for name in MANIFESTS
221
269
  if (root / name).is_file() and not (root / name).is_symlink()
222
270
  ]
271
+ manifests.extend(
272
+ sorted(
273
+ path.name
274
+ for path in root.iterdir()
275
+ if path.is_file()
276
+ and not path.is_symlink()
277
+ and path.suffix.lower() in {".sln", ".csproj", ".fsproj", ".vbproj"}
278
+ )
279
+ )
223
280
  checks.append(
224
281
  Check(
225
282
  "project-manifest",
@@ -255,14 +312,29 @@ def audit_repository(root: Path) -> list[Check]:
255
312
  )
256
313
  )
257
314
 
258
- risky: list[str] = []
315
+ tracked_files = _git_tracked_files(root)
316
+ risky: set[str] = set()
317
+
259
318
  for path in _iter_project_files(root):
260
- name = path.name.lower()
261
- if name in RISK_FILENAMES or name.endswith(RISK_SUFFIXES):
262
- risky.append(path.relative_to(root).as_posix())
319
+ if _is_risky_filename(path.name):
320
+ risky.add(path.relative_to(root).as_posix())
321
+
322
+ # Generated/dependency directories are pruned from the filesystem walk to
323
+ # avoid expensive scans. Tracked files are different: if a risky filename
324
+ # is committed, it must still be surfaced even inside a pruned directory.
325
+ if tracked_files is not None:
326
+ risky.update(
327
+ relative
328
+ for relative in tracked_files
329
+ if _is_risky_filename(Path(relative).name)
330
+ )
263
331
 
264
332
  if risky:
265
- tracked, ignored, untracked = _classify_risky_paths(root, risky)
333
+ tracked, ignored, untracked = _classify_risky_paths(
334
+ root,
335
+ sorted(risky),
336
+ tracked_files,
337
+ )
266
338
  parts: list[str] = []
267
339
  if tracked:
268
340
  parts.append(f"tracked: {_preview(tracked)}")
@@ -41,6 +41,11 @@ def _parser() -> argparse.ArgumentParser:
41
41
  action="store_true",
42
42
  help="Return a non-zero exit code when AI instruction integrity findings are present",
43
43
  )
44
+ preflight.add_argument(
45
+ "--require-ready",
46
+ action="store_true",
47
+ help="Return a non-zero exit code unless the preflight state is READY",
48
+ )
44
49
 
45
50
  fix = sub.add_parser("fix", help="Preview safe repository-readiness fixes")
46
51
  fix.add_argument("path", nargs="?", default=".")
@@ -85,6 +90,7 @@ def _run_preflight(
85
90
  sarif_path: str | None,
86
91
  strict: bool,
87
92
  fail_on_integrity: bool,
93
+ require_ready: bool,
88
94
  ) -> int:
89
95
  root = Path(path).expanduser().resolve()
90
96
  if not root.exists() or not root.is_dir():
@@ -161,6 +167,8 @@ def _run_preflight(
161
167
  return 1
162
168
  if fail_on_integrity and report["instruction_summary"]["findings"]:
163
169
  return 1
170
+ if require_ready and report["state"] != "READY":
171
+ return 1
164
172
  return 0
165
173
 
166
174
 
@@ -292,6 +300,7 @@ def main(argv: list[str] | None = None) -> int:
292
300
  args.sarif_path,
293
301
  args.strict,
294
302
  args.fail_on_integrity,
303
+ args.require_ready,
295
304
  )
296
305
  if args.command == "fix":
297
306
  return _run_fix(args.path, args.apply)
@@ -148,15 +148,108 @@ def _frontmatter_value(text: str, keys: tuple[str, ...]) -> str | None:
148
148
  return None
149
149
 
150
150
 
151
+ def _split_top_level_commas(value: str) -> list[str]:
152
+ items: list[str] = []
153
+ current: list[str] = []
154
+ quote: str | None = None
155
+ escaped = False
156
+ braces = 0
157
+ brackets = 0
158
+ parens = 0
159
+
160
+ for char in value:
161
+ if escaped:
162
+ current.append(char)
163
+ escaped = False
164
+ continue
165
+
166
+ if char == "\\":
167
+ current.append(char)
168
+ escaped = True
169
+ continue
170
+
171
+ if quote:
172
+ current.append(char)
173
+ if char == quote:
174
+ quote = None
175
+ continue
176
+
177
+ if char in {"'", '"'}:
178
+ quote = char
179
+ current.append(char)
180
+ continue
181
+
182
+ if char == "{":
183
+ braces += 1
184
+ elif char == "}":
185
+ braces = max(0, braces - 1)
186
+ elif char == "[":
187
+ brackets += 1
188
+ elif char == "]":
189
+ brackets = max(0, brackets - 1)
190
+ elif char == "(":
191
+ parens += 1
192
+ elif char == ")":
193
+ parens = max(0, parens - 1)
194
+
195
+ if char == "," and braces == 0 and brackets == 0 and parens == 0:
196
+ item = "".join(current).strip()
197
+ if item:
198
+ items.append(item)
199
+ current = []
200
+ continue
201
+
202
+ current.append(char)
203
+
204
+ item = "".join(current).strip()
205
+ if item:
206
+ items.append(item)
207
+ return items
208
+
209
+
210
+ def _strip_matching_quotes(value: str) -> str:
211
+ value = value.strip()
212
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
213
+ return value[1:-1].strip()
214
+ return value
215
+
216
+
151
217
  def _split_patterns(value: str) -> list[str]:
152
- value = value.strip().strip('"').strip("'")
218
+ value = value.strip()
219
+
220
+ # Inline YAML arrays are common, but a glob character class such as [ab]
221
+ # is also valid. Only treat brackets as an array when the interior looks
222
+ # list-like.
153
223
  if value.startswith("[") and value.endswith("]"):
154
- value = value[1:-1]
155
- return [
156
- item.strip().strip('"').strip("'")
157
- for item in value.split(",")
158
- if item.strip().strip('"').strip("'")
159
- ]
224
+ inner = value[1:-1].strip()
225
+ if "," in inner or inner.startswith(("'", '"')):
226
+ value = inner
227
+
228
+ raw_items = _split_top_level_commas(value)
229
+ patterns: list[str] = []
230
+
231
+ for raw in raw_items:
232
+ item = _strip_matching_quotes(raw)
233
+ if not item:
234
+ continue
235
+
236
+ # Copilot commonly wraps alternatives in one quoted brace expression:
237
+ # "{src/**/test/**,src/**/*.test.ts}". Expand only a brace that wraps
238
+ # the whole selector; embedded brace expansion stays intact so
239
+ # apps/web/{src,tests}/** still has static prefix apps/web.
240
+ if item.startswith("{") and item.endswith("}"):
241
+ alternatives = [
242
+ _strip_matching_quotes(part)
243
+ for part in _split_top_level_commas(item[1:-1])
244
+ ]
245
+ alternatives = [part for part in alternatives if part]
246
+ if len(alternatives) > 1:
247
+ patterns.extend(alternatives)
248
+ continue
249
+
250
+ patterns.append(item)
251
+
252
+ return patterns
160
253
 
161
254
 
162
255
  def _static_prefix(pattern: str) -> str:
@@ -171,6 +171,23 @@ def build_preflight(root: Path) -> dict[str, object]:
171
171
  checks = audit_repository(root)
172
172
  instructions = detect_instruction_signals(root)
173
173
  instruction_findings = lint_instructions(root, instructions)
174
+
175
+ root_manager_conflict = any(
176
+ check.name == "package-manager-evidence"
177
+ and check.status != "pass"
178
+ and check.message.startswith("Conflicting Node.js package-manager evidence")
179
+ for check in checks
180
+ )
181
+ if root_manager_conflict:
182
+ instruction_findings = [
183
+ finding
184
+ for finding in instruction_findings
185
+ if not (
186
+ finding.kind == "package-manager-evidence-conflict"
187
+ and finding.scope == "."
188
+ )
189
+ ]
190
+
174
191
  instruction_totals = finding_summary(instruction_findings)
175
192
  projects = detect_project_signals(root)
176
193
  totals = summary(checks)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-workspace-bootstrap
3
- Version: 0.6.1
3
+ Version: 0.7.0
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
@@ -123,7 +123,7 @@ The goal is not a vanity score. The result is one of:
123
123
  - README
124
124
  - license
125
125
  - `.gitignore`
126
- - common project manifests
126
+ - common project manifests, including Python, Node.js, Go, Rust, JVM, and .NET roots
127
127
  - local Git / Python / Node.js / repository-selected package manager / PowerShell / WSL / Codex signals
128
128
 
129
129
  ### AI instruction coverage
@@ -153,7 +153,7 @@ The lint is intentionally conservative: different files may contain additional c
153
153
 
154
154
  ### Risk signals
155
155
 
156
- The audit warns about common secret-bearing filenames without printing their contents. When Git is available, it distinguishes **tracked**, **ignored**, and **untracked/unknown** candidates. Tracked risky filenames can become blocking findings in strict mode.
156
+ The audit warns about common secret-bearing filenames without printing their contents, including environment-specific `.env.*` files while excluding common template names such as `.env.example` and `.env.sample`. When Git is available, it distinguishes **tracked**, **ignored**, and **untracked/unknown** candidates. Tracked risky filenames can become blocking findings in strict mode.
157
157
 
158
158
  This is intentionally a lightweight preflight check, not a replacement for deep scanners such as Gitleaks or Trivy.
159
159
 
@@ -211,6 +211,14 @@ Fail CI on any instruction-integrity finding:
211
211
  cwb preflight . --fail-on-integrity
212
212
  ```
213
213
 
214
+ Require the full preflight state to be `READY`:
215
+
216
+ ```powershell
217
+ cwb preflight . --require-ready
218
+ ```
219
+
220
+ Use `--require-ready` when CI should reject both `NEEDS ATTENTION` and `BLOCKED`, including missing repository-wide AI instructions or essential repository markers.
221
+
214
222
  ### Detailed audit
215
223
 
216
224
  ```powershell
@@ -248,7 +256,7 @@ cwb doctor .
248
256
  cwb init-agents .
249
257
  ```
250
258
 
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.
259
+ Generation is evidence-based: the tool identifies common Python, Node.js, Go, Rust, JVM, and .NET project roots, then only emits validation commands that have direct repository evidence. Python/Node-specific validation is cross-checked against 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
260
 
253
261
  ## GitHub Action
254
262
 
@@ -259,11 +267,12 @@ The reusable Action is published on GitHub Marketplace.
259
267
  - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
260
268
  with:
261
269
  python-version: "3.13"
262
- - uses: kohli217/codex-workspace-bootstrap@v0.6.1
270
+ - uses: kohli217/codex-workspace-bootstrap@v0.7.0
263
271
  with:
264
272
  path: .
265
273
  strict: "true"
266
274
  fail_on_integrity: "true"
275
+ require_ready: "true"
267
276
  ```
268
277
 
269
278
  The Action adds the preflight Markdown report to the **GitHub Actions job summary**, so maintainers get a readable readiness snapshot without digging through raw logs. Optional SARIF output contains both repository-audit and instruction-integrity findings and can be uploaded to GitHub Code Scanning.
@@ -325,7 +334,7 @@ py -m pip install codex-workspace-bootstrap
325
334
  Pinned GitHub release artifact:
326
335
 
327
336
  ```powershell
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"
337
+ py -m pip install "https://github.com/kohli217/codex-workspace-bootstrap/releases/download/v0.7.0/codex_workspace_bootstrap-0.7.0-py3-none-any.whl"
329
338
  ```
330
339
 
331
340
  ## Development
@@ -4,7 +4,7 @@ import subprocess
4
4
 
5
5
  import pytest
6
6
 
7
- from codex_workspace_bootstrap.audit import audit_repository, summary
7
+ from codex_workspace_bootstrap.audit import _git_tracked_files, audit_repository, summary
8
8
 
9
9
 
10
10
  def _git(root: Path, *args: str) -> None:
@@ -206,3 +206,119 @@ def test_audit_does_not_accept_symlinked_repository_markers(
206
206
  assert by_name["gitignore"].status == "warn"
207
207
  assert by_name["agents"].status == "warn"
208
208
  assert by_name["project-manifest"].status == "warn"
209
+
210
+
211
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
212
+ def test_tracked_risky_file_inside_pruned_directory_is_blocking(tmp_path: Path) -> None:
213
+ _git(tmp_path, "init")
214
+ nested = tmp_path / "node_modules" / "pkg"
215
+ nested.mkdir(parents=True)
216
+ (nested / ".env").write_text("EXAMPLE=tracked\n", encoding="utf-8")
217
+ _git(tmp_path, "add", "-f", "node_modules/pkg/.env")
218
+
219
+ checks = audit_repository(tmp_path)
220
+ by_name = {check.name: check for check in checks}
221
+
222
+ risk = by_name["secret-risk-files"]
223
+ assert risk.status == "warn"
224
+ assert risk.blocking is True
225
+ assert "node_modules/pkg/.env" in risk.message
226
+ assert "tracked:" in risk.message
227
+
228
+
229
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
230
+ def test_tracked_risky_file_inside_dist_is_blocking(tmp_path: Path) -> None:
231
+ _git(tmp_path, "init")
232
+ dist = tmp_path / "dist"
233
+ dist.mkdir()
234
+ (dist / "release.key").write_text("not-a-real-key\n", encoding="utf-8")
235
+ _git(tmp_path, "add", "-f", "dist/release.key")
236
+
237
+ checks = audit_repository(tmp_path)
238
+ by_name = {check.name: check for check in checks}
239
+
240
+ risk = by_name["secret-risk-files"]
241
+ assert risk.blocking is True
242
+ assert "dist/release.key" in risk.message
243
+
244
+
245
+ @pytest.mark.parametrize(
246
+ "filename",
247
+ [
248
+ "build.gradle.kts",
249
+ "settings.gradle",
250
+ "settings.gradle.kts",
251
+ "demo.sln",
252
+ "demo.csproj",
253
+ "demo.fsproj",
254
+ "demo.vbproj",
255
+ ],
256
+ )
257
+ def test_audit_recognizes_extended_project_manifests(tmp_path: Path, filename: str) -> None:
258
+ (tmp_path / filename).write_text("marker\n", encoding="utf-8")
259
+
260
+ checks = audit_repository(tmp_path)
261
+ by_name = {check.name: check for check in checks}
262
+
263
+ assert by_name["project-manifest"].status == "pass"
264
+ assert filename in by_name["project-manifest"].message
265
+
266
+
267
+ @pytest.mark.parametrize(
268
+ "filename",
269
+ [
270
+ ".env.production",
271
+ ".env.development.local",
272
+ ".env.test",
273
+ ".env.staging",
274
+ ],
275
+ )
276
+ def test_audit_warns_on_environment_specific_secret_risk_files(
277
+ tmp_path: Path,
278
+ filename: str,
279
+ ) -> None:
280
+ (tmp_path / filename).write_text("TOKEN=not-a-real-secret\n", encoding="utf-8")
281
+
282
+ checks = audit_repository(tmp_path)
283
+ by_name = {check.name: check for check in checks}
284
+
285
+ risk = by_name["secret-risk-files"]
286
+ assert risk.status == "warn"
287
+ assert filename in risk.message
288
+
289
+
290
+ @pytest.mark.parametrize(
291
+ "filename",
292
+ [
293
+ ".env.example",
294
+ ".env.sample",
295
+ ".env.template",
296
+ ".env.dist",
297
+ ".env.defaults",
298
+ ],
299
+ )
300
+ def test_audit_does_not_flag_common_env_templates(tmp_path: Path, filename: str) -> None:
301
+ (tmp_path / filename).write_text("TOKEN=replace-me\n", encoding="utf-8")
302
+
303
+ checks = audit_repository(tmp_path)
304
+ by_name = {check.name: check for check in checks}
305
+
306
+ assert by_name["secret-risk-files"].status == "pass"
307
+
308
+
309
+ def test_git_tracked_files_uses_surrogateescape_for_path_bytes(
310
+ tmp_path: Path,
311
+ monkeypatch,
312
+ ) -> None:
313
+ class Result:
314
+ returncode = 0
315
+ stdout = b"normal.txt\0invalid-\xff.env\0"
316
+
317
+ monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/git")
318
+ monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: Result())
319
+
320
+ tracked = _git_tracked_files(tmp_path)
321
+
322
+ assert tracked is not None
323
+ assert "normal.txt" in tracked
324
+ assert any(name.endswith(".env") for name in tracked)
@@ -11,7 +11,7 @@ def test_init_agents_creates_file(tmp_path: Path) -> None:
11
11
  code = main(["init-agents", str(tmp_path)])
12
12
  assert code == 0
13
13
  content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
14
- assert "No common Python or Node.js manifest detected" in content
14
+ assert "No supported project manifest detected" in content
15
15
 
16
16
 
17
17
  def test_init_agents_generates_python_validation(tmp_path: Path) -> None:
@@ -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.1" in capsys.readouterr().out
104
+ assert "codex-workspace-bootstrap 0.7.0" in capsys.readouterr().out
105
105
 
106
106
 
107
107
  def test_audit_writes_sarif(tmp_path: Path) -> None:
@@ -296,6 +296,67 @@ def test_init_agents_ignores_symlinked_project_markers(
296
296
 
297
297
  assert code == 0
298
298
  content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
299
- assert "No common Python or Node.js manifest detected" in content
299
+ assert "No supported project manifest detected" in content
300
300
  assert "python -m pytest" not in content
301
301
  assert "pnpm run test" not in content
302
+
303
+
304
+ @pytest.mark.parametrize(
305
+ ("filename", "expected_signal"),
306
+ [
307
+ ("go.mod", "Go"),
308
+ ("Cargo.toml", "Rust"),
309
+ ("build.gradle.kts", "JVM"),
310
+ ("demo.sln", ".NET"),
311
+ ],
312
+ )
313
+ def test_init_agents_reports_common_project_signal_without_inventing_commands(
314
+ tmp_path: Path,
315
+ filename: str,
316
+ expected_signal: str,
317
+ ) -> None:
318
+ (tmp_path / filename).write_text("marker\n", encoding="utf-8")
319
+
320
+ code = main(["init-agents", str(tmp_path)])
321
+
322
+ assert code == 0
323
+ content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
324
+ assert expected_signal in content
325
+ assert "No supported project manifest detected" not in content
326
+ for unsupported_guess in ("go test", "cargo test", "gradle test", "dotnet test"):
327
+ assert unsupported_guess not in content
328
+
329
+
330
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
331
+ def test_preflight_require_ready_fails_for_needs_attention(tmp_path: Path) -> None:
332
+ subprocess.run(
333
+ ("git", "-C", str(tmp_path), "init"),
334
+ check=True,
335
+ capture_output=True,
336
+ text=True,
337
+ )
338
+ (tmp_path / "README.md").write_text("# demo\n", encoding="utf-8")
339
+ (tmp_path / ".gitignore").write_text(".venv/\n", encoding="utf-8")
340
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
341
+
342
+ code = main(["preflight", str(tmp_path), "--require-ready"])
343
+
344
+ assert code == 1
345
+
346
+
347
+ @pytest.mark.skipif(shutil.which("git") is None, reason="git is required")
348
+ def test_preflight_require_ready_succeeds_for_ready_repository(tmp_path: Path) -> None:
349
+ subprocess.run(
350
+ ("git", "-C", str(tmp_path), "init"),
351
+ check=True,
352
+ capture_output=True,
353
+ text=True,
354
+ )
355
+ (tmp_path / "README.md").write_text("# demo\n", encoding="utf-8")
356
+ (tmp_path / ".gitignore").write_text(".venv/\n", encoding="utf-8")
357
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n", encoding="utf-8")
358
+ (tmp_path / "AGENTS.md").write_text("# agents\n", encoding="utf-8")
359
+
360
+ code = main(["preflight", str(tmp_path), "--require-ready"])
361
+
362
+ assert code == 0
@@ -811,3 +811,25 @@ def test_yarn_workspace_command_validates_real_script_name(tmp_path: Path) -> No
811
811
 
812
812
  assert not any(item.kind == "missing-package-script" for item in findings)
813
813
  assert not any(item.kind == "package-manager-mismatch" for item in findings)
814
+
815
+
816
+ def test_embedded_brace_glob_keeps_parent_static_scope(tmp_path: Path) -> None:
817
+ directory = tmp_path / ".github" / "instructions"
818
+ directory.mkdir(parents=True)
819
+ (directory / "web.instructions.md").write_text(
820
+ "---\n"
821
+ 'applyTo: "apps/web/{src,tests}/**/*.ts"\n'
822
+ "---\n"
823
+ "Use web validation.\n",
824
+ encoding="utf-8",
825
+ )
826
+
827
+ signals = detect_instruction_signals(tmp_path)
828
+ signal = next(
829
+ item
830
+ for item in signals
831
+ if item.path == ".github/instructions/web.instructions.md"
832
+ )
833
+
834
+ assert signal.kind == "path-specific"
835
+ assert signal.scope == "apps/web"
@@ -216,3 +216,82 @@ def test_next_actions_prioritizes_conflicting_package_manager_evidence() -> None
216
216
  and item.title == "Resolve conflicting repository package-manager evidence"
217
217
  for item in actions
218
218
  )
219
+
220
+
221
+ def test_build_preflight_deduplicates_root_package_manager_conflict(
222
+ tmp_path: Path,
223
+ monkeypatch,
224
+ ) -> None:
225
+ (tmp_path / "package.json").write_text(
226
+ '{"packageManager":"pnpm@10","scripts":{"test":"vitest"}}',
227
+ encoding="utf-8",
228
+ )
229
+ (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
230
+ (tmp_path / "AGENTS.md").write_text("Run §pnpm test§.\n".replace("§", "`"), encoding="utf-8")
231
+
232
+ def fake_tool_check(label: str, command: tuple[str, ...]):
233
+ return Check(label, "pass", "available")
234
+
235
+ monkeypatch.setattr("codex_workspace_bootstrap.audit._tool_check", fake_tool_check)
236
+
237
+ report = build_preflight(tmp_path)
238
+
239
+ audit_conflicts = [
240
+ item
241
+ for item in report["checks"]
242
+ if item["name"] == "package-manager-evidence"
243
+ and item["message"].startswith("Conflicting Node.js package-manager evidence")
244
+ ]
245
+ instruction_conflicts = [
246
+ item
247
+ for item in report["instruction_findings"]
248
+ if item["kind"] == "package-manager-evidence-conflict"
249
+ and item["scope"] == "."
250
+ ]
251
+ conflict_actions = [
252
+ item
253
+ for item in report["next_actions"]
254
+ if "package-manager" in item["title"].lower()
255
+ and "conflict" in item["title"].lower()
256
+ ]
257
+
258
+ assert len(audit_conflicts) == 1
259
+ assert instruction_conflicts == []
260
+ assert len(conflict_actions) == 1
261
+
262
+
263
+ def test_build_preflight_keeps_nested_package_manager_conflict(
264
+ tmp_path: Path,
265
+ monkeypatch,
266
+ ) -> None:
267
+ (tmp_path / "package.json").write_text(
268
+ '{"packageManager":"npm@11","scripts":{"test":"vitest"}}',
269
+ encoding="utf-8",
270
+ )
271
+ (tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
272
+ (tmp_path / "AGENTS.md").write_text("Run §npm test§.\n".replace("§", "`"), encoding="utf-8")
273
+
274
+ nested = tmp_path / "apps" / "web"
275
+ nested.mkdir(parents=True)
276
+ (nested / "package.json").write_text(
277
+ '{"packageManager":"pnpm@10","scripts":{"test":"vitest"}}',
278
+ encoding="utf-8",
279
+ )
280
+ (nested / "package-lock.json").write_text("{}", encoding="utf-8")
281
+ (nested / "AGENTS.md").write_text("Run §pnpm test§.\n".replace("§", "`"), encoding="utf-8")
282
+
283
+ def fake_tool_check(label: str, command: tuple[str, ...]):
284
+ return Check(label, "pass", "available")
285
+
286
+ monkeypatch.setattr("codex_workspace_bootstrap.audit._tool_check", fake_tool_check)
287
+
288
+ report = build_preflight(tmp_path)
289
+
290
+ nested_conflicts = [
291
+ item
292
+ for item in report["instruction_findings"]
293
+ if item["kind"] == "package-manager-evidence-conflict"
294
+ and item["scope"] == "apps/web"
295
+ ]
296
+
297
+ assert len(nested_conflicts) == 1
@@ -1,7 +1,7 @@
1
1
  from pathlib import Path
2
2
  import json
3
3
 
4
- from codex_workspace_bootstrap.instructions import lint_instructions
4
+ from codex_workspace_bootstrap.instructions import detect_instruction_signals, lint_instructions
5
5
 
6
6
 
7
7
  def test_public_pattern_openai_codex_pnpm_repo_without_js_command_drift(tmp_path: Path) -> None:
@@ -70,3 +70,27 @@ def test_public_pattern_continue_gradle_rule_does_not_conflict_with_root_npm(tmp
70
70
  )
71
71
 
72
72
  assert lint_instructions(tmp_path) == []
73
+
74
+
75
+ def test_public_pattern_vscode_brace_wrapped_apply_to_keeps_static_scope(tmp_path: Path) -> None:
76
+ """Pattern observed in microsoft/vscode: brace-wrapped Copilot applyTo alternatives."""
77
+ directory = tmp_path / ".github" / "instructions"
78
+ directory.mkdir(parents=True)
79
+ (directory / "writing-tests.instructions.md").write_text(
80
+ "---\n"
81
+ "description: Test guidance\n"
82
+ 'applyTo: "{src/vs/**/test/**,src/vs/**/*.test.ts,src/vs/**/*.integrationTest.ts}"\n'
83
+ "---\n"
84
+ "# Writing Tests\n",
85
+ encoding="utf-8",
86
+ )
87
+
88
+ signals = detect_instruction_signals(tmp_path)
89
+ signal = next(
90
+ item
91
+ for item in signals
92
+ if item.path == ".github/instructions/writing-tests.instructions.md"
93
+ )
94
+
95
+ assert signal.kind == "path-specific"
96
+ assert signal.scope == "src/vs"
@@ -47,3 +47,39 @@ def test_documented_third_party_actions_are_sha_pinned() -> None:
47
47
  f"{relative} documents mutable ref {action}@{ref}; "
48
48
  "pin third-party actions to a full commit SHA"
49
49
  )
50
+
51
+
52
+ def test_pypi_auto_publish_uses_triggering_release_artifact() -> None:
53
+ release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
54
+ publish = (ROOT / ".github" / "workflows" / "pypi-publish.yml").read_text(encoding="utf-8")
55
+
56
+ assert "name: python-package-distributions" in release
57
+ assert "dist/*.whl" in release
58
+ assert "dist/*.tar.gz" in release
59
+ assert "python -m twine check dist/*" in release
60
+
61
+ assert "github.event.workflow_run.id" in publish
62
+ assert "github-token: ${{ github.token }}" in publish
63
+ assert "Download exact distributions from triggering Release run" in publish
64
+ assert "gh release view" not in publish
65
+
66
+
67
+ def test_release_documentation_matches_attestation_permissions() -> None:
68
+ docs = (ROOT / "docs" / "RELEASING.md").read_text(encoding="utf-8")
69
+
70
+ for permission in (
71
+ "contents: write",
72
+ "id-token: write",
73
+ "attestations: write",
74
+ "artifact-metadata: write",
75
+ ):
76
+ assert f"`{permission}`" in docs
77
+
78
+
79
+ def test_powershell_installer_default_matches_package_version() -> None:
80
+ version = _project_version()
81
+ installer = (ROOT / "scripts" / "install.ps1").read_text(encoding="utf-8")
82
+
83
+ assert f'[string]$Version = "{version}"' in installer
84
+ assert "releases/download/v$Version/codex_workspace_bootstrap-$Version-py3-none-any.whl" in installer
85
+ assert 'Write-Host " cwb preflight ."' in installer