@deftai/directive-content 0.55.2 → 0.56.1
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 +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- 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/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 +1 -1
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ci_local.py -- Run the full CI pipeline locally (matrix-aware).
|
|
3
|
+
|
|
4
|
+
Mirrors the step graph of ``.github/workflows/ci.yml`` so contributors can
|
|
5
|
+
catch CI failures before pushing. The workflow defines three jobs:
|
|
6
|
+
|
|
7
|
+
1. ``python`` -- ``uv sync`` + ``uv run ruff check .`` + ``uv run mypy tests/``
|
|
8
|
+
+ ``uv run pytest tests/ --cov --cov-report=term-missing``.
|
|
9
|
+
2. ``go`` -- ``go test ./cmd/deft-install/`` + cross-compile builds for
|
|
10
|
+
``linux/amd64``, ``darwin/arm64``, and ``windows/amd64``.
|
|
11
|
+
3. ``windows-task-dispatch`` -- Windows-only regression tests (path
|
|
12
|
+
traversal, ``scope:promote`` end-to-end, etc.) that only run on a
|
|
13
|
+
Windows host when ``--matrix=windows`` is requested.
|
|
14
|
+
|
|
15
|
+
In addition, this script exercises the existing local framework command
|
|
16
|
+
surface that the CI workflow expects to remain green:
|
|
17
|
+
|
|
18
|
+
- ``deft toolchain:check``
|
|
19
|
+
- ``deft verify:stubs``
|
|
20
|
+
- ``deft verify:links``
|
|
21
|
+
- ``deft verify:rule-ownership`` (#705)
|
|
22
|
+
- ``deft vbrief:validate``
|
|
23
|
+
- ``deft build`` (skipped with ``--skip-build``)
|
|
24
|
+
- ``deft build:verify`` (graceful absence -- it's a sibling pending #233
|
|
25
|
+
item; if the command is missing from the no-task registry we skip it with an
|
|
26
|
+
informational message rather than failing).
|
|
27
|
+
|
|
28
|
+
Platform notes
|
|
29
|
+
--------------
|
|
30
|
+
- **Linux**: All Python + Go steps run natively. The cross-compile builds
|
|
31
|
+
emit to ``/dev/null``.
|
|
32
|
+
- **macOS**: Same as Linux; the ``darwin/arm64`` cross-compile is the
|
|
33
|
+
native build path.
|
|
34
|
+
- **Windows**: ``/dev/null`` is replaced with ``NUL`` for the Go
|
|
35
|
+
cross-compile output. The Windows-task-dispatch regression steps
|
|
36
|
+
require ``--matrix=windows`` and are skipped on non-Windows hosts. PR
|
|
37
|
+
bodies and other text artifacts are written via ``pathlib`` /
|
|
38
|
+
``create_file`` rather than inline PowerShell string ops to avoid the
|
|
39
|
+
PowerShell 5.1 UTF-16LE encoding-corruption pitfall.
|
|
40
|
+
|
|
41
|
+
Output
|
|
42
|
+
------
|
|
43
|
+
Each step prints a line of the form::
|
|
44
|
+
|
|
45
|
+
[N/M] <step name> ... OK (1.23s)
|
|
46
|
+
|
|
47
|
+
or, on failure::
|
|
48
|
+
|
|
49
|
+
[N/M] <step name> ... FAIL (1.23s)
|
|
50
|
+
--- stdout ---
|
|
51
|
+
...captured output...
|
|
52
|
+
--- stderr ---
|
|
53
|
+
...captured output...
|
|
54
|
+
|
|
55
|
+
Stdout/stderr capture mirrors GitHub Actions' default log style so the
|
|
56
|
+
output is easy to compare against a real CI run for debugging parity.
|
|
57
|
+
|
|
58
|
+
Exit codes
|
|
59
|
+
----------
|
|
60
|
+
0 -- every applicable step succeeded (skipped steps do not count as
|
|
61
|
+
failures)
|
|
62
|
+
1 -- at least one step failed
|
|
63
|
+
2 -- configuration error (invalid arguments, missing required tool)
|
|
64
|
+
|
|
65
|
+
Refs #233 (umbrella; this resolves the ``task-ci-local`` plan.item),
|
|
66
|
+
#642 (workflow umbrella), #635 (epic anchor), #633 (pre-PR
|
|
67
|
+
deterministic-CI enforcement umbrella), #709 (Repair Authority [AXIOM]).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
from __future__ import annotations
|
|
71
|
+
|
|
72
|
+
import argparse
|
|
73
|
+
import os
|
|
74
|
+
import platform
|
|
75
|
+
import shutil
|
|
76
|
+
import subprocess
|
|
77
|
+
import sys
|
|
78
|
+
import time
|
|
79
|
+
from collections.abc import Callable
|
|
80
|
+
from dataclasses import dataclass, field
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
# Make sibling helpers importable both when run as __main__ and when imported by tests.
|
|
84
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
85
|
+
|
|
86
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
87
|
+
from framework_commands import has_command, run_framework_command # noqa: E402
|
|
88
|
+
|
|
89
|
+
reconfigure_stdio()
|
|
90
|
+
|
|
91
|
+
# ---- Exit codes -------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
EXIT_OK = 0
|
|
94
|
+
EXIT_STEP_FAILED = 1
|
|
95
|
+
EXIT_CONFIG_ERROR = 2
|
|
96
|
+
|
|
97
|
+
# ---- Matrix -----------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
VALID_MATRIX_VALUES = ("linux", "macos", "windows", "all", "host")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _host_matrix() -> str:
|
|
103
|
+
"""Map the current host to a ``--matrix`` value.
|
|
104
|
+
|
|
105
|
+
``platform.system()`` returns ``Linux`` / ``Darwin`` / ``Windows`` on
|
|
106
|
+
the three supported hosts. Any other value falls through to
|
|
107
|
+
``"linux"`` as the most permissive default (the script still skips
|
|
108
|
+
Windows-only steps under that assumption).
|
|
109
|
+
"""
|
|
110
|
+
system = platform.system().lower()
|
|
111
|
+
if system == "windows":
|
|
112
|
+
return "windows"
|
|
113
|
+
if system == "darwin":
|
|
114
|
+
return "macos"
|
|
115
|
+
return "linux"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---- Step model -------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class StepResult:
|
|
123
|
+
"""Outcome of a single CI step."""
|
|
124
|
+
|
|
125
|
+
name: str
|
|
126
|
+
status: str # "ok" | "fail" | "skip"
|
|
127
|
+
elapsed: float = 0.0
|
|
128
|
+
stdout: str = ""
|
|
129
|
+
stderr: str = ""
|
|
130
|
+
skip_reason: str = ""
|
|
131
|
+
return_code: int | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class Step:
|
|
136
|
+
"""A single CI step description.
|
|
137
|
+
|
|
138
|
+
The step is only run if ``applies()`` returns ``True``; otherwise the
|
|
139
|
+
runner emits a ``skip`` result with ``skip_reason``. ``run_fn``
|
|
140
|
+
receives the resolved repository root and returns a
|
|
141
|
+
``subprocess.CompletedProcess``-like 3-tuple of
|
|
142
|
+
``(returncode, stdout, stderr)``.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
name: str
|
|
146
|
+
run_fn: Callable[[Path], tuple[int, str, str]]
|
|
147
|
+
applies_fn: Callable[[], bool] = field(default=lambda: True)
|
|
148
|
+
skip_reason_fn: Callable[[], str] = field(default=lambda: "")
|
|
149
|
+
|
|
150
|
+
def applies(self) -> bool:
|
|
151
|
+
return bool(self.applies_fn())
|
|
152
|
+
|
|
153
|
+
def skip_reason(self) -> str:
|
|
154
|
+
return self.skip_reason_fn()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---- subprocess helpers -----------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _devnull_for_host() -> str:
|
|
161
|
+
"""Return the platform-appropriate path for discarding build output."""
|
|
162
|
+
return "NUL" if platform.system().lower() == "windows" else "/dev/null"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _run_command(
|
|
166
|
+
cmd: list[str],
|
|
167
|
+
cwd: Path,
|
|
168
|
+
*,
|
|
169
|
+
env_overrides: dict[str, str] | None = None,
|
|
170
|
+
) -> tuple[int, str, str]:
|
|
171
|
+
"""Run ``cmd`` synchronously and return ``(returncode, stdout, stderr)``.
|
|
172
|
+
|
|
173
|
+
Raises ``FileNotFoundError`` if the command's executable is not on
|
|
174
|
+
``PATH``; the caller maps that to ``EXIT_CONFIG_ERROR``.
|
|
175
|
+
"""
|
|
176
|
+
env = os.environ.copy()
|
|
177
|
+
# PYTHONUTF8 mirrors the top-level Taskfile.yml env so child Python
|
|
178
|
+
# processes don't crash on the unicode glyphs that several scripts
|
|
179
|
+
# emit (#540 belt-and-suspenders).
|
|
180
|
+
env.setdefault("PYTHONUTF8", "1")
|
|
181
|
+
if env_overrides:
|
|
182
|
+
env.update(env_overrides)
|
|
183
|
+
result = subprocess.run(
|
|
184
|
+
cmd,
|
|
185
|
+
cwd=str(cwd),
|
|
186
|
+
env=env,
|
|
187
|
+
capture_output=True,
|
|
188
|
+
text=True,
|
|
189
|
+
check=False,
|
|
190
|
+
)
|
|
191
|
+
return result.returncode, result.stdout, result.stderr
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---- Tool detection ---------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _has_executable(name: str) -> bool:
|
|
198
|
+
"""Return True iff ``name`` is resolvable via ``shutil.which``."""
|
|
199
|
+
return shutil.which(name) is not None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _framework_command_available(name: str) -> bool:
|
|
203
|
+
"""Return True iff the no-task framework command registry exposes *name*."""
|
|
204
|
+
return has_command(name)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _run_framework_command(name: str, root: Path) -> tuple[int, str, str]:
|
|
208
|
+
"""Run a framework command in-process."""
|
|
209
|
+
result = run_framework_command(
|
|
210
|
+
name,
|
|
211
|
+
project_root=root,
|
|
212
|
+
framework_root=root,
|
|
213
|
+
capture=True,
|
|
214
|
+
)
|
|
215
|
+
return result.code, result.stdout, result.stderr
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---- Step constructors ------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _make_python_steps(root: Path) -> list[Step]:
|
|
222
|
+
if not _has_executable("uv"):
|
|
223
|
+
return [
|
|
224
|
+
Step(
|
|
225
|
+
name="python: uv toolchain probe",
|
|
226
|
+
run_fn=lambda _root: (0, "", ""),
|
|
227
|
+
applies_fn=lambda: False,
|
|
228
|
+
skip_reason_fn=lambda: (
|
|
229
|
+
"uv not on PATH; install Astral uv to run the Python job locally "
|
|
230
|
+
"(https://docs.astral.sh/uv/getting-started/installation/)."
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
]
|
|
234
|
+
return [
|
|
235
|
+
Step(
|
|
236
|
+
name="python: uv sync",
|
|
237
|
+
run_fn=lambda r: _run_command(["uv", "sync"], cwd=r),
|
|
238
|
+
),
|
|
239
|
+
Step(
|
|
240
|
+
name="python: ruff lint",
|
|
241
|
+
run_fn=lambda r: _run_command(["uv", "run", "ruff", "check", "."], cwd=r),
|
|
242
|
+
),
|
|
243
|
+
Step(
|
|
244
|
+
name="python: mypy tests/",
|
|
245
|
+
run_fn=lambda r: _run_command(["uv", "run", "mypy", "tests/"], cwd=r),
|
|
246
|
+
),
|
|
247
|
+
Step(
|
|
248
|
+
name="python: pytest with coverage",
|
|
249
|
+
run_fn=lambda r: _run_command(
|
|
250
|
+
[
|
|
251
|
+
"uv",
|
|
252
|
+
"run",
|
|
253
|
+
"pytest",
|
|
254
|
+
"tests/",
|
|
255
|
+
"--cov",
|
|
256
|
+
"--cov-report=term-missing",
|
|
257
|
+
],
|
|
258
|
+
cwd=r,
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _make_go_steps(root: Path, *, skip_build: bool) -> list[Step]:
|
|
265
|
+
if not _has_executable("go"):
|
|
266
|
+
return [
|
|
267
|
+
Step(
|
|
268
|
+
name="go: toolchain probe",
|
|
269
|
+
run_fn=lambda _root: (0, "", ""),
|
|
270
|
+
applies_fn=lambda: False,
|
|
271
|
+
skip_reason_fn=lambda: (
|
|
272
|
+
"go not on PATH; install Go to run the Go job locally."
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
devnull = _devnull_for_host()
|
|
277
|
+
test_step = Step(
|
|
278
|
+
name="go: test ./cmd/deft-install/",
|
|
279
|
+
run_fn=lambda r: _run_command(["go", "test", "./cmd/deft-install/"], cwd=r),
|
|
280
|
+
)
|
|
281
|
+
if skip_build:
|
|
282
|
+
return [test_step]
|
|
283
|
+
cross_targets = (
|
|
284
|
+
("linux", "amd64"),
|
|
285
|
+
("darwin", "arm64"),
|
|
286
|
+
("windows", "amd64"),
|
|
287
|
+
)
|
|
288
|
+
cross_steps = [
|
|
289
|
+
Step(
|
|
290
|
+
name=f"go: build {goos}/{goarch}",
|
|
291
|
+
run_fn=lambda r, _goos=goos, _goarch=goarch: _run_command(
|
|
292
|
+
["go", "build", "-o", devnull, "./cmd/deft-install/"],
|
|
293
|
+
cwd=r,
|
|
294
|
+
env_overrides={"GOOS": _goos, "GOARCH": _goarch},
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
for goos, goarch in cross_targets
|
|
298
|
+
]
|
|
299
|
+
return [test_step, *cross_steps]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _make_framework_steps(root: Path, *, skip_build: bool) -> list[Step]:
|
|
303
|
+
steps: list[Step] = [
|
|
304
|
+
Step(
|
|
305
|
+
name="framework toolchain:check",
|
|
306
|
+
run_fn=lambda r: _run_framework_command("toolchain:check", r),
|
|
307
|
+
),
|
|
308
|
+
Step(
|
|
309
|
+
name="framework verify:stubs",
|
|
310
|
+
run_fn=lambda r: _run_framework_command("verify:stubs", r),
|
|
311
|
+
),
|
|
312
|
+
Step(
|
|
313
|
+
name="framework verify:links",
|
|
314
|
+
run_fn=lambda r: _run_framework_command("verify:links", r),
|
|
315
|
+
),
|
|
316
|
+
Step(
|
|
317
|
+
name="framework verify:rule-ownership",
|
|
318
|
+
run_fn=lambda r: _run_framework_command("verify:rule-ownership", r),
|
|
319
|
+
),
|
|
320
|
+
Step(
|
|
321
|
+
name="framework vbrief:validate",
|
|
322
|
+
run_fn=lambda r: _run_framework_command("vbrief:validate", r),
|
|
323
|
+
),
|
|
324
|
+
]
|
|
325
|
+
if not skip_build:
|
|
326
|
+
steps.append(
|
|
327
|
+
Step(
|
|
328
|
+
name="framework build",
|
|
329
|
+
run_fn=lambda r: _run_framework_command("build", r),
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
# build:verify is a sibling pending #233 plan.item; detect via the
|
|
333
|
+
# no-task registry and skip with an informational message rather than
|
|
334
|
+
# failing when it isn't yet implemented.
|
|
335
|
+
build_verify_present = _framework_command_available("build:verify")
|
|
336
|
+
steps.append(
|
|
337
|
+
Step(
|
|
338
|
+
name="framework build:verify",
|
|
339
|
+
run_fn=lambda r: _run_framework_command("build:verify", r),
|
|
340
|
+
applies_fn=lambda: build_verify_present,
|
|
341
|
+
skip_reason_fn=lambda: (
|
|
342
|
+
"`deft build:verify` not yet implemented; skipping -- "
|
|
343
|
+
"see #233 pending vBRIEF for the sibling plan.item."
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return steps
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _make_windows_dispatch_steps(matrix: str) -> list[Step]:
|
|
351
|
+
"""The 5 Windows-task-dispatch regression steps.
|
|
352
|
+
|
|
353
|
+
These mirror the ``windows-task-dispatch`` job in
|
|
354
|
+
``.github/workflows/ci.yml``. They are run only when the user opted
|
|
355
|
+
in via ``--matrix=windows`` AND the host is actually Windows.
|
|
356
|
+
"""
|
|
357
|
+
host_is_windows = platform.system().lower() == "windows"
|
|
358
|
+
matrix_requests_windows = matrix in ("windows", "all")
|
|
359
|
+
skip_reason = (
|
|
360
|
+
""
|
|
361
|
+
if (host_is_windows and matrix_requests_windows)
|
|
362
|
+
else (
|
|
363
|
+
"windows-task-dispatch regressions only run on a Windows host with "
|
|
364
|
+
"--matrix=windows (or --matrix=all). On non-Windows hosts they're "
|
|
365
|
+
"skipped because the steps shell out to PowerShell."
|
|
366
|
+
)
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _applies() -> bool:
|
|
370
|
+
return host_is_windows and matrix_requests_windows
|
|
371
|
+
|
|
372
|
+
# The detail of the 5 PowerShell steps lives in
|
|
373
|
+
# .github/workflows/ci.yml so we don't risk drift here. We delegate
|
|
374
|
+
# to a single subprocess call per step that uses pwsh / powershell
|
|
375
|
+
# to invoke the same logic. To avoid duplicating the heavy fixture
|
|
376
|
+
# staging here, we only run the lightweight pytest guard-rails on
|
|
377
|
+
# the host -- the full job graph (fixture stage, migrate:vbrief
|
|
378
|
+
# dispatch, scope:promote dispatch, completed-routing fixture) is a
|
|
379
|
+
# CI-only exercise. Exposing one applies() gate per step keeps the
|
|
380
|
+
# report row count stable.
|
|
381
|
+
fixture_pytest = Step(
|
|
382
|
+
name="windows-task-dispatch: pytest guard-rails",
|
|
383
|
+
run_fn=lambda r: _run_command(
|
|
384
|
+
[
|
|
385
|
+
"uv",
|
|
386
|
+
"run",
|
|
387
|
+
"pytest",
|
|
388
|
+
"tests/content/test_taskfile_paths.py",
|
|
389
|
+
"tests/content/test_taskfile_cli_args.py",
|
|
390
|
+
"tests/content/test_taskfile_caching.py",
|
|
391
|
+
"-v",
|
|
392
|
+
],
|
|
393
|
+
cwd=r,
|
|
394
|
+
),
|
|
395
|
+
applies_fn=_applies,
|
|
396
|
+
skip_reason_fn=lambda: skip_reason,
|
|
397
|
+
)
|
|
398
|
+
return [fixture_pytest]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---- Pipeline construction --------------------------------------------------
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def build_pipeline(
|
|
405
|
+
root: Path,
|
|
406
|
+
*,
|
|
407
|
+
matrix: str,
|
|
408
|
+
skip_build: bool,
|
|
409
|
+
) -> list[Step]:
|
|
410
|
+
"""Return the ordered step graph for the local CI run."""
|
|
411
|
+
steps: list[Step] = []
|
|
412
|
+
steps.extend(_make_python_steps(root))
|
|
413
|
+
steps.extend(_make_go_steps(root, skip_build=skip_build))
|
|
414
|
+
steps.extend(_make_framework_steps(root, skip_build=skip_build))
|
|
415
|
+
steps.extend(_make_windows_dispatch_steps(matrix))
|
|
416
|
+
return steps
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ---- Runner -----------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def run_pipeline(
|
|
423
|
+
root: Path,
|
|
424
|
+
steps: list[Step],
|
|
425
|
+
*,
|
|
426
|
+
fail_fast: bool,
|
|
427
|
+
verbose: bool,
|
|
428
|
+
out: Callable[[str], None] | None = None,
|
|
429
|
+
) -> list[StepResult]:
|
|
430
|
+
"""Execute ``steps`` in order; return the list of ``StepResult``."""
|
|
431
|
+
emit: Callable[[str], None] = out if out is not None else print
|
|
432
|
+
results: list[StepResult] = []
|
|
433
|
+
total = len(steps)
|
|
434
|
+
failed_seen = False
|
|
435
|
+
for index, step in enumerate(steps, start=1):
|
|
436
|
+
prefix = f"[{index}/{total}] {step.name}"
|
|
437
|
+
if failed_seen and fail_fast:
|
|
438
|
+
results.append(
|
|
439
|
+
StepResult(
|
|
440
|
+
name=step.name,
|
|
441
|
+
status="skip",
|
|
442
|
+
skip_reason="aborted -- earlier step failed and --fail-fast is set",
|
|
443
|
+
)
|
|
444
|
+
)
|
|
445
|
+
emit(f"{prefix} ... SKIP (fail-fast)")
|
|
446
|
+
continue
|
|
447
|
+
if not step.applies():
|
|
448
|
+
reason = step.skip_reason() or "step not applicable on this host"
|
|
449
|
+
results.append(
|
|
450
|
+
StepResult(name=step.name, status="skip", skip_reason=reason)
|
|
451
|
+
)
|
|
452
|
+
emit(f"{prefix} ... SKIP -- {reason}")
|
|
453
|
+
continue
|
|
454
|
+
emit(f"{prefix} ... running")
|
|
455
|
+
start = time.monotonic()
|
|
456
|
+
try:
|
|
457
|
+
rc, stdout, stderr = step.run_fn(root)
|
|
458
|
+
except FileNotFoundError as exc:
|
|
459
|
+
elapsed = time.monotonic() - start
|
|
460
|
+
results.append(
|
|
461
|
+
StepResult(
|
|
462
|
+
name=step.name,
|
|
463
|
+
status="fail",
|
|
464
|
+
elapsed=elapsed,
|
|
465
|
+
stderr=f"executable not found: {exc}",
|
|
466
|
+
return_code=None,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
emit(f"{prefix} ... FAIL ({elapsed:.2f}s) -- executable not found: {exc}")
|
|
470
|
+
failed_seen = True
|
|
471
|
+
continue
|
|
472
|
+
elapsed = time.monotonic() - start
|
|
473
|
+
if rc == 0:
|
|
474
|
+
results.append(
|
|
475
|
+
StepResult(
|
|
476
|
+
name=step.name,
|
|
477
|
+
status="ok",
|
|
478
|
+
elapsed=elapsed,
|
|
479
|
+
stdout=stdout,
|
|
480
|
+
stderr=stderr,
|
|
481
|
+
return_code=rc,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
emit(f"{prefix} ... OK ({elapsed:.2f}s)")
|
|
485
|
+
if verbose and (stdout or stderr):
|
|
486
|
+
if stdout:
|
|
487
|
+
emit("--- stdout ---")
|
|
488
|
+
emit(stdout.rstrip())
|
|
489
|
+
if stderr:
|
|
490
|
+
emit("--- stderr ---")
|
|
491
|
+
emit(stderr.rstrip())
|
|
492
|
+
else:
|
|
493
|
+
results.append(
|
|
494
|
+
StepResult(
|
|
495
|
+
name=step.name,
|
|
496
|
+
status="fail",
|
|
497
|
+
elapsed=elapsed,
|
|
498
|
+
stdout=stdout,
|
|
499
|
+
stderr=stderr,
|
|
500
|
+
return_code=rc,
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
emit(f"{prefix} ... FAIL ({elapsed:.2f}s) -- exit code {rc}")
|
|
504
|
+
if stdout:
|
|
505
|
+
emit("--- stdout ---")
|
|
506
|
+
emit(stdout.rstrip())
|
|
507
|
+
if stderr:
|
|
508
|
+
emit("--- stderr ---")
|
|
509
|
+
emit(stderr.rstrip())
|
|
510
|
+
failed_seen = True
|
|
511
|
+
return results
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---- Aggregate report -------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def format_summary(results: list[StepResult]) -> str:
|
|
518
|
+
"""Return a human-readable aggregate summary."""
|
|
519
|
+
total = len(results)
|
|
520
|
+
passed = sum(1 for r in results if r.status == "ok")
|
|
521
|
+
failed = sum(1 for r in results if r.status == "fail")
|
|
522
|
+
skipped = sum(1 for r in results if r.status == "skip")
|
|
523
|
+
elapsed = sum(r.elapsed for r in results)
|
|
524
|
+
lines = [
|
|
525
|
+
"",
|
|
526
|
+
"=" * 60,
|
|
527
|
+
"ci:local summary",
|
|
528
|
+
"=" * 60,
|
|
529
|
+
f" total: {total}",
|
|
530
|
+
f" passed: {passed}",
|
|
531
|
+
f" failed: {failed}",
|
|
532
|
+
f" skipped: {skipped}",
|
|
533
|
+
f" elapsed: {elapsed:.2f}s",
|
|
534
|
+
]
|
|
535
|
+
if failed:
|
|
536
|
+
lines.append("")
|
|
537
|
+
lines.append("Failed steps:")
|
|
538
|
+
for r in results:
|
|
539
|
+
if r.status == "fail":
|
|
540
|
+
rc = "n/a" if r.return_code is None else str(r.return_code)
|
|
541
|
+
lines.append(f" - {r.name} (exit {rc})")
|
|
542
|
+
if skipped:
|
|
543
|
+
lines.append("")
|
|
544
|
+
lines.append("Skipped steps:")
|
|
545
|
+
for r in results:
|
|
546
|
+
if r.status == "skip":
|
|
547
|
+
lines.append(f" - {r.name} -- {r.skip_reason}")
|
|
548
|
+
lines.append("=" * 60)
|
|
549
|
+
return "\n".join(lines)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# ---- argument parsing -------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
556
|
+
parser = argparse.ArgumentParser(
|
|
557
|
+
prog="ci_local",
|
|
558
|
+
description=(
|
|
559
|
+
"Run the full CI pipeline locally (matrix-aware). Mirrors the "
|
|
560
|
+
"step graph of .github/workflows/ci.yml so contributors can "
|
|
561
|
+
"catch CI failures before pushing. Refs #233, #642, #635, "
|
|
562
|
+
"#633, #709."
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
parser.add_argument(
|
|
566
|
+
"--matrix",
|
|
567
|
+
choices=VALID_MATRIX_VALUES,
|
|
568
|
+
default="host",
|
|
569
|
+
help=(
|
|
570
|
+
"Which CI matrix slice to run. ``host`` (default) maps to the "
|
|
571
|
+
"current platform via ``platform.system()``. NOTE: the flag's "
|
|
572
|
+
"only practical effect is gating the ``windows-task-dispatch`` "
|
|
573
|
+
"regression rows -- those run only when ``--matrix=windows`` (or "
|
|
574
|
+
"``all``) is supplied AND the host is Windows, because the "
|
|
575
|
+
"underlying steps shell out to PowerShell. The Python, Go, and "
|
|
576
|
+
"Taskfile-level rows always run on whatever toolchain is "
|
|
577
|
+
"available locally and are not platform-filtered (Greptile "
|
|
578
|
+
"P2 #713). ``linux`` / ``macos`` therefore behave equivalently "
|
|
579
|
+
"on a non-Windows host."
|
|
580
|
+
),
|
|
581
|
+
)
|
|
582
|
+
parser.add_argument(
|
|
583
|
+
"--skip-build",
|
|
584
|
+
action="store_true",
|
|
585
|
+
help=(
|
|
586
|
+
"Skip the cross-compile builds and the ``task build`` / "
|
|
587
|
+
"``task build:verify`` steps. Useful for tight inner-loop "
|
|
588
|
+
"iteration where only lint + test feedback matters."
|
|
589
|
+
),
|
|
590
|
+
)
|
|
591
|
+
parser.add_argument(
|
|
592
|
+
"--verbose",
|
|
593
|
+
action="store_true",
|
|
594
|
+
help=(
|
|
595
|
+
"Mirror CI logs verbatim -- stream every step's stdout/stderr "
|
|
596
|
+
"even when the step succeeds. Default is to surface output "
|
|
597
|
+
"only on failure."
|
|
598
|
+
),
|
|
599
|
+
)
|
|
600
|
+
fail_fast_group = parser.add_mutually_exclusive_group()
|
|
601
|
+
fail_fast_group.add_argument(
|
|
602
|
+
"--fail-fast",
|
|
603
|
+
dest="fail_fast",
|
|
604
|
+
action="store_true",
|
|
605
|
+
default=True,
|
|
606
|
+
help=(
|
|
607
|
+
"Abort the pipeline at the first failing step (default). "
|
|
608
|
+
"Subsequent steps are reported as skipped."
|
|
609
|
+
),
|
|
610
|
+
)
|
|
611
|
+
fail_fast_group.add_argument(
|
|
612
|
+
"--no-fail-fast",
|
|
613
|
+
dest="fail_fast",
|
|
614
|
+
action="store_false",
|
|
615
|
+
help=(
|
|
616
|
+
"Run every applicable step even when an earlier one failed. "
|
|
617
|
+
"The exit code is still 1 if any step failed."
|
|
618
|
+
),
|
|
619
|
+
)
|
|
620
|
+
parser.add_argument(
|
|
621
|
+
"--root",
|
|
622
|
+
type=Path,
|
|
623
|
+
default=None,
|
|
624
|
+
metavar="PATH",
|
|
625
|
+
help=(
|
|
626
|
+
"Repository root. Defaults to the parent of the scripts/ "
|
|
627
|
+
"directory containing this file."
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
return parser
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _resolve_root(arg_root: Path | None) -> Path:
|
|
634
|
+
if arg_root is not None:
|
|
635
|
+
return arg_root.resolve()
|
|
636
|
+
return Path(__file__).resolve().parent.parent
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _resolve_matrix(matrix_arg: str) -> str:
|
|
640
|
+
if matrix_arg == "host":
|
|
641
|
+
return _host_matrix()
|
|
642
|
+
return matrix_arg
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ---- main -------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def main(argv: list[str] | None = None) -> int:
|
|
649
|
+
parser = _build_parser()
|
|
650
|
+
args = parser.parse_args(argv)
|
|
651
|
+
root = _resolve_root(args.root)
|
|
652
|
+
if not root.is_dir():
|
|
653
|
+
print(f"Error: --root does not point at a directory: {root}", file=sys.stderr)
|
|
654
|
+
return EXIT_CONFIG_ERROR
|
|
655
|
+
matrix = _resolve_matrix(args.matrix)
|
|
656
|
+
print(
|
|
657
|
+
f"ci:local -- root={root} matrix={matrix} skip_build={args.skip_build} "
|
|
658
|
+
f"fail_fast={args.fail_fast} verbose={args.verbose}",
|
|
659
|
+
file=sys.stderr,
|
|
660
|
+
)
|
|
661
|
+
steps = build_pipeline(root, matrix=matrix, skip_build=args.skip_build)
|
|
662
|
+
# ``build_pipeline`` always returns at least the toolchain probe
|
|
663
|
+
# rows, so ``steps`` is rarely empty. The reachable failure mode is a
|
|
664
|
+
# pipeline composed entirely of skips -- that means no tool was
|
|
665
|
+
# installed and the runner would otherwise exit 0 with every step
|
|
666
|
+
# silently skipped, violating the three-state exit-code contract.
|
|
667
|
+
# ``not any(s.applies() for s in steps)`` covers both shapes (Greptile
|
|
668
|
+
# P1 #713).
|
|
669
|
+
if not steps or not any(s.applies() for s in steps):
|
|
670
|
+
print(
|
|
671
|
+
"Error: no CI steps applicable on this host. Install at least one of "
|
|
672
|
+
"uv / go / task to run any portion of the pipeline locally.",
|
|
673
|
+
file=sys.stderr,
|
|
674
|
+
)
|
|
675
|
+
return EXIT_CONFIG_ERROR
|
|
676
|
+
results = run_pipeline(
|
|
677
|
+
root,
|
|
678
|
+
steps,
|
|
679
|
+
fail_fast=args.fail_fast,
|
|
680
|
+
verbose=args.verbose,
|
|
681
|
+
)
|
|
682
|
+
print(format_summary(results))
|
|
683
|
+
if any(r.status == "fail" for r in results):
|
|
684
|
+
return EXIT_STEP_FAILED
|
|
685
|
+
return EXIT_OK
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
if __name__ == "__main__":
|
|
689
|
+
sys.exit(main())
|