@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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- 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())
|