@deftai/directive-content 0.55.1 → 0.56.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 (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,171 @@
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())
@@ -0,0 +1,509 @@
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())