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