@deftai/directive-content 0.59.0 → 0.61.0

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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,171 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Fail when runtime Python code hard-depends on go-task (#1659)."""
3
-
4
- from __future__ import annotations
5
-
6
- import ast
7
- import sys
8
- from dataclasses import dataclass
9
- from pathlib import Path
10
-
11
- ROOT = Path(__file__).resolve().parent.parent
12
- SCAN_PATHS = (ROOT / "run", ROOT / "scripts")
13
- SUBPROCESS_FUNCS = {"run", "check_call", "check_output", "Popen", "call"}
14
-
15
-
16
- @dataclass(frozen=True)
17
- class Finding:
18
- path: Path
19
- line: int
20
- message: str
21
-
22
-
23
- def _is_name_or_attr(node: ast.AST, dotted: str) -> bool:
24
- parts = dotted.split(".")
25
- current = node
26
- for expected in reversed(parts):
27
- if isinstance(current, ast.Attribute):
28
- if current.attr != expected:
29
- return False
30
- current = current.value
31
- continue
32
- if isinstance(current, ast.Name):
33
- return current.id == expected and expected == parts[0]
34
- return False
35
- return isinstance(current, ast.Name) and current.id == parts[0]
36
-
37
-
38
- def _literal_first_arg(call: ast.Call) -> str | None:
39
- if not call.args:
40
- return None
41
- first = call.args[0]
42
- if isinstance(first, ast.Constant) and isinstance(first.value, str):
43
- return first.value
44
- if isinstance(first, (ast.List, ast.Tuple)) and first.elts:
45
- head = first.elts[0]
46
- if isinstance(head, ast.Constant) and isinstance(head.value, str):
47
- return head.value
48
- return None
49
-
50
-
51
- class Visitor(ast.NodeVisitor):
52
- def __init__(self, path: Path) -> None:
53
- self.path = path
54
- self.findings: list[Finding] = []
55
- self.subprocess_names = {"subprocess"}
56
- self.subprocess_func_names: set[str] = set()
57
- self.shutil_names = {"shutil"}
58
- self.shutil_which_names: set[str] = set()
59
-
60
- def visit_Import(self, node: ast.Import) -> None: # noqa: N802
61
- for alias in node.names:
62
- local_name = alias.asname or alias.name
63
- if alias.name == "subprocess":
64
- self.subprocess_names.add(local_name)
65
- if alias.name == "shutil":
66
- self.shutil_names.add(local_name)
67
- self.generic_visit(node)
68
-
69
- def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: N802
70
- if node.module == "subprocess":
71
- for alias in node.names:
72
- if alias.name in SUBPROCESS_FUNCS:
73
- self.subprocess_func_names.add(alias.asname or alias.name)
74
- if node.module == "shutil":
75
- for alias in node.names:
76
- if alias.name == "which":
77
- self.shutil_which_names.add(alias.asname or alias.name)
78
- self.generic_visit(node)
79
-
80
- def visit_Call(self, node: ast.Call) -> None: # noqa: N802
81
- if isinstance(node.func, ast.Attribute):
82
- if (
83
- isinstance(node.func.value, ast.Name)
84
- and node.func.value.id in self.subprocess_names
85
- and node.func.attr in SUBPROCESS_FUNCS
86
- and _literal_first_arg(node) == "task"
87
- ):
88
- self.findings.append(
89
- Finding(
90
- self.path,
91
- node.lineno,
92
- "runtime subprocess invocation of go-task is forbidden",
93
- )
94
- )
95
- if (
96
- node.func.attr == "which"
97
- and (
98
- _is_name_or_attr(node.func.value, "shutil")
99
- or (
100
- isinstance(node.func.value, ast.Name)
101
- and node.func.value.id in self.shutil_names
102
- )
103
- )
104
- and _literal_first_arg(node) == "task"
105
- ):
106
- self.findings.append(
107
- Finding(
108
- self.path,
109
- node.lineno,
110
- "runtime go-task PATH probe is forbidden",
111
- )
112
- )
113
- if isinstance(node.func, ast.Name):
114
- if node.func.id in self.subprocess_func_names and _literal_first_arg(node) == "task":
115
- self.findings.append(
116
- Finding(
117
- self.path,
118
- node.lineno,
119
- "runtime subprocess invocation of go-task is forbidden",
120
- )
121
- )
122
- if node.func.id in self.shutil_which_names and _literal_first_arg(node) == "task":
123
- self.findings.append(
124
- Finding(
125
- self.path,
126
- node.lineno,
127
- "runtime go-task PATH probe is forbidden",
128
- )
129
- )
130
- self.generic_visit(node)
131
-
132
-
133
- def _python_files() -> list[Path]:
134
- files = [ROOT / "run"]
135
- files.extend(
136
- path
137
- for path in sorted((ROOT / "scripts").glob("*.py"))
138
- if path.name != Path(__file__).name
139
- )
140
- return files
141
-
142
-
143
- def scan() -> list[Finding]:
144
- findings: list[Finding] = []
145
- for path in _python_files():
146
- try:
147
- tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
148
- except (OSError, SyntaxError) as exc:
149
- findings.append(Finding(path, getattr(exc, "lineno", 1) or 1, str(exc)))
150
- continue
151
- visitor = Visitor(path)
152
- visitor.visit(tree)
153
- findings.extend(visitor.findings)
154
- return findings
155
-
156
-
157
- def main(argv: list[str] | None = None) -> int:
158
- _ = argv
159
- findings = scan()
160
- if not findings:
161
- print("No runtime go-task subprocess dependencies found")
162
- return 0
163
- print("Runtime go-task dependencies found:", file=sys.stderr)
164
- for finding in findings:
165
- rel = finding.path.relative_to(ROOT)
166
- print(f" {rel}:{finding.line}: {finding.message}", file=sys.stderr)
167
- return 1
168
-
169
-
170
- if __name__ == "__main__":
171
- raise SystemExit(main())
@@ -1,509 +0,0 @@
1
- #!/usr/bin/env python3
2
- """verify_scm_boundary.py -- deterministic gate against raw ``gh`` calls (#1145 / N5).
3
-
4
- Pure stdlib, cross-platform. Invoked from:
5
-
6
- - ``task verify:scm-boundary`` (aggregated into ``task check``)
7
- - ``uv run python scripts/verify_scm_boundary.py [--project-root <path>] [--allow-list <path>]``
8
-
9
- Why this gate exists
10
- --------------------
11
- Issue #1145 (N5 of the #1119 cohort) introduces ``scripts.scm.call(source,
12
- verb, args, **kwargs)`` as the single seam through which deft's verb layer
13
- (``scripts/triage_*.py``, ``scripts/scope_*.py``, ``scripts/slice_*.py``,
14
- ``scripts/issue_ingest.py``, ...) invokes the underlying SCM CLI. Pre-N5,
15
- every consumer called ``subprocess.run(["gh", ...])`` directly; the first
16
- non-GitHub consumer would have hit an undocumented coupling deep in the
17
- call stack. The shim relocates that coupling to one indirection point so
18
- the future SCM abstraction (#445 / #935 Workstream 6) has a single seam
19
- to extend.
20
-
21
- Per AGENTS.md ``## Rule Authority`` [AXIOM] this gate elevates the
22
- "no raw gh calls in the verb layer" rule from prose tier to deterministic
23
- tier -- the gate body IS the rule; the AGENTS.md cross-reference in
24
- ``## SCM tooling -- prefer ghx (#884)`` is documentation, not duplication.
25
-
26
- Scope (which files MUST go through ``scm.call``)
27
- ------------------------------------------------
28
- The verifier deliberately scopes by file glob rather than scanning every
29
- tracked Python file. Release tooling (``scripts/release*.py``,
30
- ``scripts/reconcile_issues.py``), the REST helper module
31
- (``scripts/gh_rest.py``), the ghx installer (``scripts/setup_ghx.py``),
32
- and the preflight gates (``scripts/preflight_*.py``) have legitimate
33
- direct-``gh`` responsibilities (they're release-tier or backend-tier code,
34
- not verb-layer consumers) and are NOT scoped in. The full glob set lives
35
- in :data:`SCOPE_GLOBS`.
36
-
37
- Files in scope are required to invoke ``gh`` / ``ghx`` only via
38
- ``scripts.scm.call``. Any ``subprocess.run`` / ``subprocess.check_output`` /
39
- ``subprocess.check_call`` / ``subprocess.Popen`` / ``subprocess.call`` /
40
- ``Popen`` / ``os.system`` call whose first argv element is the literal
41
- ``"gh"`` or ``"ghx"`` is a violation -- the AST scan in
42
- :func:`scan_file` flags them at the call site.
43
-
44
- Detection scope (AST inspection)
45
- --------------------------------
46
- The scan is AST-based rather than regex-based so call sites split across
47
- multiple lines (the common indented multi-line ``subprocess.run([\\n
48
- "gh", "issue", "close", ...]``) are detected reliably. We look at:
49
-
50
- - :func:`ast.Call` nodes whose ``.func`` resolves to one of
51
- ``subprocess.run`` / ``subprocess.check_output`` /
52
- ``subprocess.check_call`` / ``subprocess.Popen`` / ``subprocess.call``,
53
- ``Popen`` (the unqualified form used after
54
- ``from subprocess import Popen``), or ``os.system``.
55
- - For ``os.system`` we look at the first positional argument as a string
56
- constant; for the ``subprocess`` family we look at the first positional
57
- argument as either a list / tuple literal (whose first element is a
58
- string constant) or a single string-constant argument when
59
- ``shell=True`` is also passed.
60
- - Any of those whose first argv element matches ``GH_BINARIES``
61
- (``"gh"`` / ``"ghx"``) is recorded as a finding.
62
-
63
- False-positive guards
64
- ---------------------
65
- - ``scripts/scm.py`` is EXEMPT from scanning -- the shim itself is the
66
- one place that legitimately invokes ``gh`` / ``ghx`` directly.
67
- - ``--allow-list <path>`` accepts a newline-separated list of glob
68
- patterns for documented exceptions (e.g. a test fixture intentionally
69
- containing a raw ``gh`` call).
70
- - The :data:`SCOPE_GLOBS` set is the exhaustive enumeration of files
71
- that MUST go through the shim. Files outside the set are not scanned;
72
- they're either out-of-scope (per the #1145 "Not in scope" clause) or
73
- backend / release-tier code with legitimate direct-``gh`` needs.
74
-
75
- Exit codes (three-state, mirrors :mod:`scripts.preflight_branch` and
76
- :mod:`scripts.verify_encoding`):
77
-
78
- - ``0`` -- clean: every in-scope file uses ``scm.call`` exclusively.
79
- - ``1`` -- violations: prints per-hit ``path:line:col [helper] context``.
80
- - ``2`` -- config error: ``--allow-list`` path unreadable, ``--project-root``
81
- invalid, or unrecognised CLI shape.
82
- """
83
-
84
- from __future__ import annotations
85
-
86
- import argparse
87
- import ast
88
- import fnmatch
89
- import sys
90
- from collections.abc import Iterable
91
- from pathlib import Path
92
-
93
- #: Binary names whose presence as the first argv element marks a violation.
94
- #: ``gh`` is the canonical GitHub CLI; ``ghx`` is the #884 caching proxy
95
- #: that uses the same surface. Both should route through
96
- #: :func:`scripts.scm.call` from the verb layer so the source-aware
97
- #: indirection (NotImplementedError for non-GitHub sources) is exercised.
98
- GH_BINARIES: frozenset[str] = frozenset({"gh", "ghx"})
99
-
100
- #: Helper functions whose first positional argument we inspect. Mirrors
101
- #: :data:`SUBPROCESS_DOTTED_HELPERS` plus the bare ``Popen`` import shape
102
- #: (``from subprocess import Popen``) and ``os.system``.
103
- SUBPROCESS_DOTTED_HELPERS: frozenset[str] = frozenset({
104
- "run",
105
- "check_output",
106
- "check_call",
107
- "call",
108
- "Popen",
109
- })
110
-
111
- #: Project-root-relative glob patterns enumerating the files in scope for
112
- #: the boundary gate. Files in this set are required to invoke
113
- #: ``gh`` / ``ghx`` only via :func:`scripts.scm.call`. The set follows the
114
- #: #1145 acceptance criteria mandate plus the "any other call sites
115
- #: discovered via repo-wide grep" catch-all narrowed to the verb-layer
116
- #: glob shape -- release tooling, REST helpers, and preflight gates are
117
- #: intentionally NOT in scope.
118
- SCOPE_GLOBS: tuple[str, ...] = (
119
- # Triage verbs (public surface).
120
- "scripts/triage_*.py",
121
- # Triage verbs (private helpers consumed by the public surface).
122
- "scripts/_triage_*.py",
123
- # Scope-lifecycle verbs.
124
- "scripts/scope_*.py",
125
- "scripts/_scope_*.py",
126
- # Slice / cohort-record verbs.
127
- "scripts/slice_*.py",
128
- # Resume-condition grammar parser (consumed by triage_actions:defer).
129
- "scripts/resume_conditions.py",
130
- # Issue ingest -- the delegate target for triage:accept (#985).
131
- "scripts/issue_ingest.py",
132
- )
133
-
134
- #: Path-glob patterns auto-exempt because the file legitimately invokes
135
- #: ``gh`` / ``ghx`` as a backend-tier or shim-internal responsibility.
136
- #: Each entry is matched against the path's POSIX form (forward slashes)
137
- #: via :func:`fnmatch.fnmatchcase`. Adding to this list MUST be justified
138
- #: in the commit message and paired with a regression test that exercises
139
- #: the exempt file's existing behaviour.
140
- BUILTIN_ALLOW_LIST: tuple[str, ...] = (
141
- # The shim itself -- the one place that legitimately invokes
142
- # ``gh`` / ``ghx`` directly. Self-skip guards against a regression
143
- # where the shim is in scope of its own check.
144
- "scripts/scm.py",
145
- # The verifier's own test file -- the fixture and the test
146
- # source both contain literal ``["gh", ...]`` text and would
147
- # otherwise flag themselves. Forward-coverage is upheld by the
148
- # tests inside this file rather than by re-scanning them.
149
- "tests/cli/test_verify_scm_boundary.py",
150
- )
151
-
152
-
153
- class Finding:
154
- """One ``subprocess gh`` / ``Popen gh`` / ``os.system gh`` detection record."""
155
-
156
- __slots__ = ("path", "line", "col", "helper", "context")
157
-
158
- def __init__(
159
- self, path: str, line: int, col: int, helper: str, context: str
160
- ) -> None:
161
- self.path = path
162
- self.line = line
163
- self.col = col
164
- self.helper = helper
165
- self.context = context
166
-
167
- def render(self) -> str:
168
- ctx = self.context if len(self.context) <= 120 else self.context[:117] + "..."
169
- return f" {self.path}:{self.line}:{self.col} [{self.helper}] {ctx}"
170
-
171
-
172
- def _resolve_helper_name(func: ast.AST) -> str | None:
173
- """Return ``"subprocess.run"`` / ``"Popen"`` / ``"os.system"`` etc., or ``None``.
174
-
175
- Resolves the dotted-name shape of a :class:`ast.Call` ``.func`` node so
176
- we can decide whether the call targets one of the subprocess-family
177
- helpers we care about. Handles both attribute access
178
- (``subprocess.run(...)``) and bare-name access (``Popen(...)`` after
179
- ``from subprocess import Popen``). Anything else returns ``None``.
180
- """
181
- if isinstance(func, ast.Attribute):
182
- # subprocess.run(...) / os.system(...) -- attribute access.
183
- value = func.value
184
- if isinstance(value, ast.Name):
185
- return f"{value.id}.{func.attr}"
186
- # Deeper attribute chains (e.g. mod.subprocess.run) are unusual
187
- # and not part of our threat model; ignore.
188
- return None
189
- if isinstance(func, ast.Name):
190
- # Bare name -- only meaningful for Popen (or aliased imports we
191
- # do NOT attempt to detect; the verifier deliberately favors
192
- # false-negatives over false-positives on import-alias gymnastics).
193
- return func.id
194
- return None
195
-
196
-
197
- def _is_target_helper(helper: str) -> bool:
198
- """Return True when ``helper`` is a subprocess-family or os.system seam.
199
-
200
- ``helper`` is the dotted-or-bare name returned by
201
- :func:`_resolve_helper_name`. We accept:
202
-
203
- - ``subprocess.run`` / ``.check_output`` / ``.check_call`` /
204
- ``.call`` / ``.Popen`` (the canonical surface).
205
- - bare ``Popen`` (``from subprocess import Popen`` then ``Popen(...)``).
206
- - ``os.system`` (the legacy shell-invocation surface).
207
- """
208
- if helper == "os.system":
209
- return True
210
- if helper == "Popen":
211
- return True
212
- if helper.startswith("subprocess."):
213
- suffix = helper.split(".", 1)[1]
214
- return suffix in SUBPROCESS_DOTTED_HELPERS
215
- return False
216
-
217
-
218
- def _extract_first_argv(call: ast.Call, helper: str) -> str | None:
219
- """Return the first argv element when it is a constant string, else ``None``.
220
-
221
- For the subprocess family we look at the first positional argument:
222
-
223
- - List / tuple literal (``["gh", "issue", "close"]``) -- the first
224
- element of the literal.
225
- - Single string constant (``"gh issue close"``) -- the whole string,
226
- typically passed alongside ``shell=True``. We split on whitespace
227
- and inspect the first token.
228
-
229
- For ``os.system`` we look at the first positional argument as a
230
- string constant and split on whitespace.
231
-
232
- Anything else (variable, expression, formatted string, kwargs-only
233
- invocation) returns ``None`` -- the verifier deliberately favors
234
- false-negatives over false-positives on dynamically-built argv lists.
235
- A future agent who hides a ``gh`` invocation behind ``cmd = ["gh",
236
- ...]; subprocess.run(cmd)`` defeats the scan; we accept that limit
237
- rather than fail loudly on every variable-named argv list in the
238
- codebase. The forward-coverage contract here is "the common shape
239
- is detected"; the exotic shape is a documented gap.
240
- """
241
- if not call.args:
242
- return None
243
- first = call.args[0]
244
-
245
- if helper == "os.system":
246
- if isinstance(first, ast.Constant) and isinstance(first.value, str):
247
- tokens = first.value.strip().split()
248
- return tokens[0] if tokens else None
249
- return None
250
-
251
- # subprocess family or bare Popen.
252
- if isinstance(first, (ast.List, ast.Tuple)):
253
- if not first.elts:
254
- return None
255
- head = first.elts[0]
256
- if isinstance(head, ast.Constant) and isinstance(head.value, str):
257
- return head.value
258
- return None
259
- if isinstance(first, ast.Constant) and isinstance(first.value, str):
260
- # Single-string form -- typically paired with shell=True. Inspect
261
- # the first whitespace-delimited token so ``"gh pr list"`` is
262
- # caught the same way as ``["gh", "pr", "list"]``.
263
- tokens = first.value.strip().split()
264
- return tokens[0] if tokens else None
265
- return None
266
-
267
-
268
- def _slice_source_line(source_lines: list[str], lineno: int) -> str:
269
- """Return the source line at ``lineno`` (1-indexed) or empty string.
270
-
271
- Note: ast lineno is 1-based; list index is 0-based, hence the
272
- ``lineno - 1`` subscript.
273
- """
274
- if 1 <= lineno <= len(source_lines):
275
- return source_lines[lineno - 1].rstrip()
276
- return ""
277
-
278
-
279
- def scan_file(rel_path: str, full_path: Path) -> list[Finding]:
280
- """Scan one Python file for raw ``gh`` / ``ghx`` subprocess invocations.
281
-
282
- Returns a list of :class:`Finding` records (one per call site). An
283
- unparseable file (SyntaxError) returns an empty list -- the gate is
284
- intentionally permissive on parse failures so a single broken file
285
- does not block a whole pre-commit. Production code that parses
286
- cleanly is scanned exhaustively.
287
- """
288
- findings: list[Finding] = []
289
- try:
290
- source = full_path.read_text(encoding="utf-8")
291
- except OSError:
292
- return findings
293
- try:
294
- tree = ast.parse(source, filename=str(full_path))
295
- except SyntaxError:
296
- return findings
297
-
298
- source_lines = source.splitlines()
299
-
300
- for node in ast.walk(tree):
301
- if not isinstance(node, ast.Call):
302
- continue
303
- helper = _resolve_helper_name(node.func)
304
- if helper is None or not _is_target_helper(helper):
305
- continue
306
- first_argv = _extract_first_argv(node, helper)
307
- if first_argv not in GH_BINARIES:
308
- continue
309
- # Defensive: ast nodes carry 1-based lineno + 0-based col_offset.
310
- line = getattr(node, "lineno", 0) or 0
311
- col = (getattr(node, "col_offset", 0) or 0) + 1
312
- ctx = _slice_source_line(source_lines, line) or f"{helper}(...)"
313
- findings.append(
314
- Finding(
315
- path=rel_path,
316
- line=line,
317
- col=col,
318
- helper=helper,
319
- context=ctx.strip(),
320
- )
321
- )
322
- return findings
323
-
324
-
325
- def _load_allow_list(path: Path | None) -> list[str]:
326
- """Read newline-separated glob patterns from ``path``; ignore comments.
327
-
328
- Lines starting with ``#`` and blank lines are skipped. Returns an
329
- empty list when ``path`` is ``None``. Raises :class:`FileNotFoundError`
330
- when a non-``None`` path does not exist (caller maps to exit 2).
331
- """
332
- if path is None:
333
- return []
334
- raw = path.read_text(encoding="utf-8", errors="replace")
335
- out: list[str] = []
336
- for line in raw.splitlines():
337
- stripped = line.strip()
338
- if not stripped or stripped.startswith("#"):
339
- continue
340
- out.append(stripped)
341
- return out
342
-
343
-
344
- def _is_allow_listed(rel_path: str, patterns: Iterable[str]) -> bool:
345
- """Return True when ``rel_path`` (POSIX form) matches any glob in patterns."""
346
- return any(fnmatch.fnmatchcase(rel_path, pat) for pat in patterns)
347
-
348
-
349
- def _candidate_files(
350
- project_root: Path, scope_globs: Iterable[str]
351
- ) -> list[tuple[str, Path]]:
352
- """Resolve :data:`SCOPE_GLOBS` to existing ``(rel_path, full_path)`` pairs.
353
-
354
- The scope globs are resolved relative to ``project_root`` via
355
- :meth:`Path.glob`. Non-existent globs (e.g. ``scripts/slice_*.py``
356
- on a checkout that has not yet landed those files) are silently
357
- skipped. The output is sorted by POSIX-form rel-path for stable
358
- diagnostic ordering.
359
- """
360
- out: dict[str, Path] = {}
361
- for pattern in scope_globs:
362
- for full in project_root.glob(pattern):
363
- if not full.is_file():
364
- continue
365
- rel = full.relative_to(project_root).as_posix()
366
- out[rel] = full
367
- return sorted(out.items(), key=lambda item: item[0])
368
-
369
-
370
- def evaluate(
371
- project_root: Path,
372
- *,
373
- allow_list_path: Path | None = None,
374
- scope_globs: Iterable[str] = SCOPE_GLOBS,
375
- ) -> tuple[int, list[Finding], str]:
376
- """Pure function returning ``(exit_code, findings, human_message)``.
377
-
378
- Separated from :func:`main` so tests can drive every state without
379
- ``capsys`` plumbing or env-var leak.
380
-
381
- ``scope_globs`` is parameterised so the unit tests can scope the
382
- scan down to a temp-directory fixture without touching the real
383
- repository tree.
384
- """
385
- try:
386
- custom_globs = _load_allow_list(allow_list_path)
387
- except FileNotFoundError as exc:
388
- return 2, [], (
389
- f"verify_scm_boundary: --allow-list file not found: {exc}\n"
390
- " Recovery: pass an existing path or omit the flag."
391
- )
392
- except OSError as exc:
393
- return 2, [], (
394
- f"verify_scm_boundary: --allow-list unreadable: {exc}\n"
395
- " Recovery: check file permissions."
396
- )
397
-
398
- if not project_root.is_dir():
399
- return 2, [], (
400
- f"verify_scm_boundary: --project-root is not a directory: "
401
- f"{project_root}\n"
402
- " Recovery: pass an existing directory path."
403
- )
404
-
405
- allow_globs = list(BUILTIN_ALLOW_LIST) + custom_globs
406
-
407
- candidates = _candidate_files(project_root, scope_globs)
408
- findings: list[Finding] = []
409
- scanned = 0
410
- for rel, full in candidates:
411
- if _is_allow_listed(rel, allow_globs):
412
- continue
413
- scanned += 1
414
- findings.extend(scan_file(rel, full))
415
-
416
- if findings:
417
- files_with_hits = len({f.path for f in findings})
418
- header = (
419
- f"verify_scm_boundary: detected {len(findings)} raw "
420
- f"`gh` / `ghx` subprocess call(s) across {files_with_hits} "
421
- f"file(s) (#1145 / N5).\n"
422
- " Root cause: the verb layer is required to invoke `gh` only "
423
- "via `scripts.scm.call(source, verb, args, **kwargs)` so a "
424
- "future\n"
425
- " GitLab / Gitea / local consumer sees a loud "
426
- "`NotImplementedError` (see #445 / #935 Workstream 6) instead "
427
- "of a confusing\n"
428
- " `gh: command not found` deep in the call stack. Fix: rewrite "
429
- "the offending call sites as\n"
430
- " `import scm`\n"
431
- " `scm.call(\"github-issue\", verb, args, ...)`\n"
432
- " Allow-list a documented exception via "
433
- "`--allow-list <path>` (file with newline-separated glob "
434
- "patterns)."
435
- )
436
- body = "\n".join(f.render() for f in findings[:50])
437
- if len(findings) > 50:
438
- body += f"\n ... and {len(findings) - 50} more"
439
- return 1, findings, f"{header}\n{body}"
440
-
441
- msg = (
442
- f"verify_scm_boundary: {scanned} verb-layer file(s) clean -- "
443
- "every `gh` / `ghx` invocation routes through `scm.call` "
444
- "(#1145 / N5)."
445
- )
446
- return 0, findings, msg
447
-
448
-
449
- def _build_parser() -> argparse.ArgumentParser:
450
- parser = argparse.ArgumentParser(
451
- prog="verify_scm_boundary.py",
452
- description=(
453
- "Deterministic gate against raw `gh` / `ghx` subprocess calls "
454
- "outside `scripts/scm.py` (#1145 / N5). Scans verb-layer "
455
- "Python files for subprocess / Popen / os.system invocations "
456
- "whose first argv element is the literal `gh` or `ghx` and "
457
- "fails loud when any are found -- the verb layer is required "
458
- "to invoke `gh` only via `scm.call(source, verb, args)`."
459
- ),
460
- )
461
- parser.add_argument(
462
- "--project-root",
463
- default=".",
464
- help="Project root path (default: current working directory).",
465
- )
466
- parser.add_argument(
467
- "--allow-list",
468
- default=None,
469
- help=(
470
- "Path to a file with newline-separated glob patterns of "
471
- "documented exceptions. Lines starting with # are comments."
472
- ),
473
- )
474
- parser.add_argument(
475
- "--quiet",
476
- action="store_true",
477
- help="Suppress the OK message (errors still print).",
478
- )
479
- return parser
480
-
481
-
482
- def main(argv: list[str] | None = None) -> int:
483
- # Force UTF-8 stdout/stderr so the diagnostic output renders correctly
484
- # on a Windows console whose default codepage is cp1252 / cp437. Mirrors
485
- # the block in scripts/verify_encoding.py exactly.
486
- if hasattr(sys.stdout, "reconfigure"):
487
- sys.stdout.reconfigure(encoding="utf-8", errors="replace")
488
- if hasattr(sys.stderr, "reconfigure"):
489
- sys.stderr.reconfigure(encoding="utf-8", errors="replace")
490
-
491
- parser = _build_parser()
492
- args = parser.parse_args(argv)
493
- project_root = Path(args.project_root).resolve()
494
- allow_list_path = Path(args.allow_list).resolve() if args.allow_list else None
495
-
496
- code, _findings, msg = evaluate(
497
- project_root,
498
- allow_list_path=allow_list_path,
499
- )
500
- if code == 0:
501
- if not args.quiet:
502
- print(msg)
503
- else:
504
- print(msg, file=sys.stderr)
505
- return code
506
-
507
-
508
- if __name__ == "__main__":
509
- sys.exit(main())