@deftai/directive-content 0.55.2 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env python3
2
+ """build_dist.py -- cross-platform release-archive builder (#736).
3
+
4
+ Replaces the prior ``tasks/core.yml::build`` shape which dispatched
5
+ ``tar -czf ... --exclude=...`` on Linux / macOS but
6
+ ``Compress-Archive -Path . -DestinationPath dist\\deft-X.Y.Z.zip -Force``
7
+ on Windows. The Windows branch had no exclude list, so the resulting zip
8
+ included ``.git/`` (often hundreds of MB of history), ``backup/``,
9
+ ``node_modules/``, AND -- because ``Compress-Archive -Path .`` walks the
10
+ output directory recursively -- the prior ``dist/`` artifact, leading to
11
+ unbounded growth on every re-run.
12
+
13
+ The fix is a single Python helper using stdlib ``tarfile`` and ``zipfile``
14
+ with one canonical exclude list, dispatched as a single command from
15
+ ``tasks/core.yml::build`` (no platform split). Format selection is
16
+ data-driven by ``sys.platform`` (overridable via ``--format`` for tests).
17
+
18
+ The script is intentionally stdlib-only -- it ships with the framework
19
+ distribution itself, so any external dependency would be a chicken-and-egg
20
+ problem during release.
21
+
22
+ Windows installer manifest resources (#1441)
23
+ --------------------------------------------
24
+ This script packages the *framework* archive; it does NOT build the Go
25
+ ``deft-install`` binaries (the release workflow's ``go build`` matrix does).
26
+ The Windows binaries embed an ``asInvoker`` application manifest so Windows'
27
+ installer-detection heuristic does not auto-elevate the ``install-*.exe``
28
+ asset (which would pop a UAC prompt and break headless ``--yes`` runs). The
29
+ manifest is carried by the committed per-arch resource objects
30
+ ``cmd/deft-install/resource_windows_{amd64,arm64}.syso``; ``go build`` links
31
+ them automatically for ``GOOS=windows`` and ignores them elsewhere, so no
32
+ step here (and no extra release tooling) is required. To regenerate them
33
+ after editing ``cmd/deft-install/deft-install.manifest`` or
34
+ ``versioninfo.json``, run ``go generate ./cmd/deft-install/``.
35
+
36
+ Usage
37
+ -----
38
+ uv run python scripts/build_dist.py --version 0.22.0
39
+ uv run python scripts/build_dist.py --version 0.22.0 --format zip
40
+ uv run python scripts/build_dist.py --version 0.22.0 \\
41
+ --exclude-extra .venv,htmlcov
42
+
43
+ Exit codes
44
+ ----------
45
+ 0 -- archive written
46
+ 1 -- runtime failure (filesystem error, archive write failure)
47
+ 2 -- configuration error (missing --version / missing root)
48
+
49
+ Refs #736.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import os
56
+ import re
57
+ import sys
58
+ import tarfile
59
+ import zipfile
60
+ from pathlib import Path
61
+
62
+ # Make sibling helpers importable both when run as __main__ and when imported
63
+ # by tests via importlib.util.spec_from_file_location.
64
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
65
+
66
+ from _stdio_utf8 import reconfigure_stdio # noqa: E402
67
+
68
+ reconfigure_stdio()
69
+
70
+
71
+ # ---- Constants --------------------------------------------------------------
72
+
73
+ # Canonical exclude list. Top-level directory names that MUST never be in
74
+ # the archive per the #736 acceptance criteria + the broader .gitignore
75
+ # conventions in this repo. The match is purely by path-component name --
76
+ # any directory anywhere under the root whose basename matches one of these
77
+ # is pruned during the os.walk traversal.
78
+ #
79
+ # The first four entries (.git, dist, backup, node_modules) mirror the
80
+ # previous Linux/macOS tar exclude list verbatim so the cross-platform
81
+ # parity test in tests/content/test_taskfile_zip_parity.py can assert the
82
+ # exclude set is preserved as the task contract changes shape. The
83
+ # additional entries (__pycache__ ... .ruff_cache) extend the list for
84
+ # Python-tooling artifacts that appear in development checkouts and would
85
+ # otherwise bloat the archive.
86
+ DEFAULT_EXCLUDES: tuple[str, ...] = (
87
+ ".git",
88
+ "dist",
89
+ "backup",
90
+ "node_modules",
91
+ "__pycache__",
92
+ ".venv",
93
+ "htmlcov",
94
+ ".pytest_cache",
95
+ ".mypy_cache",
96
+ ".ruff_cache",
97
+ ".coverage",
98
+ )
99
+
100
+ # Repo-relative prefixes that should not be shipped in consumer framework
101
+ # payloads. These are project-management / forensic artifacts for this source
102
+ # repository, not runtime framework files. Keep vbrief/schemas/** and other
103
+ # runtime surfaces by pruning only the historical lifecycle folders.
104
+ DEFAULT_EXCLUDED_PATH_PREFIXES: tuple[str, ...] = (
105
+ "history/archive",
106
+ "vbrief/completed",
107
+ "vbrief/cancelled",
108
+ )
109
+
110
+ EXIT_OK = 0
111
+ EXIT_RUNTIME_ERROR = 1
112
+ EXIT_CONFIG_ERROR = 2
113
+
114
+ # Vendored TypeScript engine test-file exclusion (#1878). The dist artifact
115
+ # ships the TypeScript engine sources under packages/{cli,core}/. The engine's
116
+ # own *.test.* / *.spec.* files would be discovered by a vitest-based consumer's
117
+ # default include glob (**/*.{test,spec}.?(c|m)[jt]s?(x)) and fail their CI, so
118
+ # they are excluded from the produced artifact -- keeping the tarball/zip
119
+ # consistent with the installer-side prune (pruneVendoredTSTests in
120
+ # cmd/deft-install/deposit.go). Only test SOURCE files are dropped; non-test
121
+ # engine sources (e.g. index.ts) are retained.
122
+ _VENDORED_TS_TEST_RE = re.compile(r"(?i)\.(test|spec)\.(c|m)?[jt]sx?$")
123
+
124
+ # Archive root directory inside the produced artifact. Consumers extracting
125
+ # the tarball / zip get a single top-level ``deft/`` directory rather than
126
+ # a sea of top-level files, matching the previous tar(1) behaviour where
127
+ # ``tar -czf foo.tar.gz .`` produces ``./`` entries that extract into the
128
+ # current directory but most tools display as ``./<name>``.
129
+ ARCHIVE_ROOT = "deft"
130
+
131
+ # C1 flatten (#1875 / #1669 Wave-1 LockedDecisions). The #1875 move relocated
132
+ # every shippable asset under a single ``content/`` root in the SOURCE repo.
133
+ # The consumer-facing deposit layout (``.deft/core/<x>``) MUST stay byte-stable,
134
+ # so the archive strips the ``content/`` prefix when packaging: a source file at
135
+ # ``content/coding/coding.md`` ships as ``deft/coding/coding.md`` and deposits to
136
+ # ``.deft/core/coding/coding.md`` exactly as before the move. Non-content entries
137
+ # (engine / harness / repo-dev) and the named root harness-entry files
138
+ # (AGENTS.md / main.md / SKILL.md / REFERENCES.md) are unaffected. This is the
139
+ # single flatten point -- the Go installer (cmd/deft-install) needs no change.
140
+ CONTENT_PREFIX = "content/"
141
+
142
+
143
+ def _flatten_content_prefix(rel_posix: str) -> str:
144
+ """Strip the leading ``content/`` prefix so the deposit stays byte-stable."""
145
+ if rel_posix == "content":
146
+ return rel_posix
147
+ if rel_posix.startswith(CONTENT_PREFIX):
148
+ return rel_posix[len(CONTENT_PREFIX) :]
149
+ return rel_posix
150
+
151
+
152
+ # ---- Path filtering ---------------------------------------------------------
153
+
154
+
155
+ def _iter_source_files(
156
+ root: Path,
157
+ excludes: frozenset[str],
158
+ excluded_prefixes: tuple[str, ...] = DEFAULT_EXCLUDED_PATH_PREFIXES,
159
+ ) -> list[tuple[Path, str]]:
160
+ """Return a sorted list of ``(absolute_path, archive_relative_posix)``.
161
+
162
+ Walks ``root`` skipping any directory or file whose basename matches
163
+ an entry in ``excludes``. Returns deterministic ordering so the
164
+ produced archive is reproducible across runs and across platforms
165
+ (the task contract ``test_idempotent_rerun`` depends on this).
166
+
167
+ Three pruning paths apply:
168
+
169
+ 1. Directory pruning -- ``dirnames`` is mutated in place so any
170
+ directory whose basename matches an exclude is skipped along with
171
+ its entire subtree. The dist/ output dir is implicitly pruned by
172
+ being in the canonical exclude list, which delivers the
173
+ idempotency guarantee called out in the #736 acceptance criteria.
174
+ 2. File pruning -- bare filenames whose basename matches an exclude
175
+ (e.g. ``.coverage`` is written as a single regular file at the
176
+ repo root by coverage.py, NOT a directory) are skipped. Without
177
+ this branch the directory-only prune would silently fail to honor
178
+ the documented intent for file-shaped artifacts (Greptile P1
179
+ review on PR #773).
180
+ 3. Path-prefix pruning -- directories and files whose repo-relative POSIX
181
+ path starts with an entry in ``excluded_prefixes`` are dropped, keeping
182
+ consumer archives free of source-repo forensic history while preserving
183
+ runtime siblings such as ``vbrief/schemas/**``.
184
+ 4. Vendored TS test-file pruning -- files under ``packages/`` whose basename
185
+ matches the vitest test glob (``*.test.*`` / ``*.spec.*``) are dropped so
186
+ a vitest-based consumer never discovers the framework's own tests, while
187
+ non-test engine sources are retained (#1878).
188
+ """
189
+ entries: list[tuple[Path, str]] = []
190
+ for dirpath, dirnames, filenames in os.walk(root):
191
+ # Mutate dirnames in place to prune the walk -- canonical os.walk
192
+ # idiom. Sort for determinism.
193
+ kept_dirnames: list[str] = []
194
+ for dirname in sorted(dirnames):
195
+ if dirname in excludes:
196
+ continue
197
+ child = Path(dirpath) / dirname
198
+ try:
199
+ child_rel = child.relative_to(root).as_posix()
200
+ except ValueError:
201
+ continue
202
+ if _matches_excluded_prefix(child_rel, excluded_prefixes):
203
+ continue
204
+ kept_dirnames.append(dirname)
205
+ dirnames[:] = kept_dirnames
206
+ for fname in sorted(filenames):
207
+ if fname in excludes:
208
+ # File-level pruning -- catches single-file artifacts
209
+ # like .coverage that os.walk surfaces in `filenames`,
210
+ # not `dirnames`.
211
+ continue
212
+ abs_path = Path(dirpath) / fname
213
+ try:
214
+ rel = abs_path.relative_to(root)
215
+ except ValueError:
216
+ # Defensive: os.walk should never yield a path outside
217
+ # root, but symlinked traversals can in theory.
218
+ continue
219
+ rel_posix = rel.as_posix()
220
+ if _matches_excluded_prefix(rel_posix, excluded_prefixes):
221
+ continue
222
+ if _is_vendored_ts_test(rel_posix):
223
+ # Drop the vendored TS engine's own test files so a
224
+ # vitest-based consumer does not discover and fail on them,
225
+ # mirroring the installer-side prune (#1878).
226
+ continue
227
+ # C1 flatten: ship content/<x> as <x> so the deposit is byte-stable.
228
+ entries.append((abs_path, _flatten_content_prefix(rel_posix)))
229
+ return entries
230
+
231
+
232
+ def _matches_excluded_prefix(rel_posix: str, prefixes: tuple[str, ...]) -> bool:
233
+ """Return True when ``rel_posix`` is at or below an excluded path prefix."""
234
+ return any(rel_posix == prefix or rel_posix.startswith(f"{prefix}/") for prefix in prefixes)
235
+
236
+
237
+ def _is_vendored_ts_test(rel_posix: str) -> bool:
238
+ """Return True for a vendored TypeScript engine test SOURCE file (#1878).
239
+
240
+ True only when the repo-relative POSIX path is under ``packages/`` AND its
241
+ basename matches the vitest-discoverable test glob (``*.test.*`` /
242
+ ``*.spec.*`` with a ``[jt]s``/``x``/``c``/``m`` extension). Non-test engine
243
+ sources under ``packages/`` (e.g. ``packages/core/src/index.ts``) return
244
+ False so only the framework's own tests are dropped from the artifact.
245
+ """
246
+ if rel_posix != "packages" and not rel_posix.startswith("packages/"):
247
+ return False
248
+ basename = rel_posix.rsplit("/", 1)[-1]
249
+ return _VENDORED_TS_TEST_RE.search(basename) is not None
250
+
251
+
252
+ # ---- Archive writers --------------------------------------------------------
253
+
254
+
255
+ def _write_tar_gz(root: Path, output: Path, entries: list[tuple[Path, str]]) -> int:
256
+ """Write a gzipped tar archive of ``entries`` to ``output``.
257
+
258
+ Each entry is added under the ``ARCHIVE_ROOT`` prefix so extraction
259
+ yields a single top-level directory.
260
+ """
261
+ output.parent.mkdir(parents=True, exist_ok=True)
262
+ count = 0
263
+ with tarfile.open(output, "w:gz") as tar:
264
+ for abs_path, rel in entries:
265
+ tar.add(abs_path, arcname=f"{ARCHIVE_ROOT}/{rel}", recursive=False)
266
+ count += 1
267
+ return count
268
+
269
+
270
+ def _write_zip(root: Path, output: Path, entries: list[tuple[Path, str]]) -> int:
271
+ """Write a deflate-compressed zip archive of ``entries`` to ``output``."""
272
+ output.parent.mkdir(parents=True, exist_ok=True)
273
+ count = 0
274
+ with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
275
+ for abs_path, rel in entries:
276
+ zf.write(abs_path, arcname=f"{ARCHIVE_ROOT}/{rel}")
277
+ count += 1
278
+ return count
279
+
280
+
281
+ # ---- Format / path resolution ----------------------------------------------
282
+
283
+
284
+ def select_format(arg: str | None) -> str:
285
+ """Return the archive format token (``tar`` or ``zip``).
286
+
287
+ When ``arg`` is None the platform default applies: ``zip`` on Windows
288
+ (``sys.platform`` startswith ``win``), ``tar`` everywhere else.
289
+ """
290
+ if arg:
291
+ return arg.lower()
292
+ if sys.platform.startswith("win"):
293
+ return "zip"
294
+ return "tar"
295
+
296
+
297
+ def output_path(root: Path, version: str, fmt: str) -> Path:
298
+ """Return the final artifact path for ``version`` + ``fmt`` under root."""
299
+ suffix = "zip" if fmt == "zip" else "tar.gz"
300
+ return root / "dist" / f"deft-{version}.{suffix}"
301
+
302
+
303
+ # ---- Public build entry point ----------------------------------------------
304
+
305
+
306
+ def build(
307
+ root: Path,
308
+ version: str,
309
+ fmt: str,
310
+ extra_excludes: tuple[str, ...] = (),
311
+ ) -> Path:
312
+ """Produce the release archive and return its path.
313
+
314
+ Idempotency: the canonical exclude list contains ``dist`` so the
315
+ output directory is pruned during traversal -- a stale prior artifact
316
+ sitting at ``dist/deft-<version>.<ext>`` cannot be ingested into the
317
+ new archive. As a belt-and-suspenders guard we also unlink the target
318
+ output file if it already exists so the new archive is fresh.
319
+ """
320
+ excludes = frozenset((*DEFAULT_EXCLUDES, *extra_excludes))
321
+ output = output_path(root, version, fmt)
322
+ if output.exists():
323
+ output.unlink()
324
+ entries = _iter_source_files(root, excludes)
325
+ if fmt == "zip":
326
+ _write_zip(root, output, entries)
327
+ else:
328
+ _write_tar_gz(root, output, entries)
329
+ return output
330
+
331
+
332
+ # ---- CLI --------------------------------------------------------------------
333
+
334
+
335
+ def _build_parser() -> argparse.ArgumentParser:
336
+ parser = argparse.ArgumentParser(
337
+ prog="build_dist.py",
338
+ description=(
339
+ "Build a cross-platform release archive (#736). Default format "
340
+ "is tar.gz on Linux/macOS and zip on Windows; override with "
341
+ "--format. Excludes .git, dist, backup, node_modules, and "
342
+ "Python-tooling caches by default."
343
+ ),
344
+ )
345
+ parser.add_argument(
346
+ "--version",
347
+ required=True,
348
+ help=(
349
+ "Version string used as the archive filename suffix "
350
+ "(e.g. 0.22.0). Passed by tasks/core.yml::build via "
351
+ "{{.VERSION}}, which itself resolves through "
352
+ "scripts/resolve_version.py's priority chain."
353
+ ),
354
+ )
355
+ parser.add_argument(
356
+ "--format",
357
+ choices=("tar", "zip"),
358
+ default=None,
359
+ help=(
360
+ "Archive format override. tar=tar.gz, zip=zip. Default is "
361
+ "platform-driven (zip on Windows, tar.gz elsewhere)."
362
+ ),
363
+ )
364
+ parser.add_argument(
365
+ "--root",
366
+ type=Path,
367
+ default=None,
368
+ help=("Repository root to package (default: parent of the scripts/ directory)."),
369
+ )
370
+ parser.add_argument(
371
+ "--exclude-extra",
372
+ default="",
373
+ help=(
374
+ "Comma-separated extra directory basenames to exclude in "
375
+ "addition to the canonical list."
376
+ ),
377
+ )
378
+ return parser
379
+
380
+
381
+ def _parse_extras(raw: str) -> tuple[str, ...]:
382
+ """Split ``raw`` (comma-separated) into a tuple, stripping empties."""
383
+ return tuple(p.strip() for p in raw.split(",") if p.strip())
384
+
385
+
386
+ def main(argv: list[str] | None = None) -> int:
387
+ parser = _build_parser()
388
+ args = parser.parse_args(argv)
389
+ if not args.version:
390
+ print("error: --version is required", file=sys.stderr)
391
+ return EXIT_CONFIG_ERROR
392
+ root = (args.root or Path(__file__).resolve().parent.parent).resolve()
393
+ if not root.is_dir():
394
+ print(f"error: root not found: {root}", file=sys.stderr)
395
+ return EXIT_CONFIG_ERROR
396
+ fmt = select_format(args.format)
397
+ extras = _parse_extras(args.exclude_extra)
398
+ try:
399
+ out = build(root, args.version, fmt, extras)
400
+ except OSError as exc:
401
+ print(f"error: {exc}", file=sys.stderr)
402
+ return EXIT_RUNTIME_ERROR
403
+ try:
404
+ printable = out.relative_to(root)
405
+ except ValueError:
406
+ printable = out
407
+ print(f"Created {printable}")
408
+ return EXIT_OK
409
+
410
+
411
+ if __name__ == "__main__":
412
+ sys.exit(main())