@deftai/directive-content 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,205 +0,0 @@
1
- """_session_start_hook.py -- session-start ritual sentinel writer (#1269).
2
-
3
- Thin wrapper around :func:`scripts.ritual_sentinel.write`. The
4
- session-start ritual orchestration calls this module at exit to persist
5
- the sentinel; the module is intentionally minimal so the orchestrator
6
- side can shell out without re-implementing the on-disk shape.
7
-
8
- Today no canonical session-start orchestrator script exists in
9
- ``deft``; this CLI is the entry point a future orchestrator will wire
10
- into, and meanwhile operators can invoke it manually::
11
-
12
- python scripts/_session_start_hook.py --write
13
-
14
- The ``--write`` flag derives the sentinel payload from the current
15
- ``git`` state:
16
-
17
- * ``deftVersion`` -- resolved via :mod:`resolve_version` (the same
18
- priority chain ``task build`` consumes; #723).
19
- * ``lastBranch`` -- ``git symbolic-ref --short HEAD`` (with
20
- ``git rev-parse --short HEAD`` as the detached-HEAD fallback,
21
- recorded as ``"detached:<short-sha>"`` when HEAD is detached).
22
- * ``lastActiveVbrief`` -- the most-recently-modified
23
- ``vbrief/active/*.vbrief.json`` file, recorded as a POSIX-style
24
- relative path. If no candidate file exists, the hook exits ``2``
25
- with a one-line diagnostic to stderr instead of writing an
26
- incomplete sentinel.
27
-
28
- Exit codes
29
- ----------
30
-
31
- * ``0`` -- sentinel written.
32
- * ``2`` -- precondition not satisfied (no active vBRIEF, no git repo,
33
- etc.). The ritual treats this as fail-open: ritual continues silently.
34
- * ``1`` -- unexpected error (re-raised :class:`Exception` from the
35
- writer's ``except`` branch). Surfaces the underlying error to stderr.
36
-
37
- The hook is intentionally side-effect-free beyond the sentinel write;
38
- it does NOT mutate git state, the cache, or the vBRIEF lifecycle.
39
-
40
- Refs #1269.
41
- """
42
-
43
- from __future__ import annotations
44
-
45
- import argparse
46
- import os
47
- import subprocess
48
- import sys
49
- from pathlib import Path
50
-
51
- # Sibling-script import (mirrors the pattern used by
52
- # ``scripts/resume_conditions.py`` and the other scripts/ modules).
53
- _SCRIPTS_DIR = Path(__file__).resolve().parent
54
- if str(_SCRIPTS_DIR) not in sys.path:
55
- sys.path.insert(0, str(_SCRIPTS_DIR))
56
-
57
- import resolve_version # type: ignore[import-not-found] # noqa: E402
58
- import ritual_sentinel # type: ignore[import-not-found] # noqa: E402
59
-
60
-
61
- def _detect_branch(project_root: Path) -> str | None:
62
- """Return the current git branch (or short SHA on detached HEAD)."""
63
- try:
64
- result = subprocess.run(
65
- ["git", "symbolic-ref", "--short", "HEAD"],
66
- cwd=str(project_root),
67
- capture_output=True,
68
- text=True,
69
- timeout=10,
70
- check=False,
71
- )
72
- except (FileNotFoundError, subprocess.TimeoutExpired):
73
- return None
74
- if result.returncode == 0:
75
- branch = (result.stdout or "").strip()
76
- if branch:
77
- return branch
78
- # Detached HEAD -- fall back to the short SHA so the sentinel still
79
- # records *something* the operator can correlate with their checkout.
80
- try:
81
- rev_result = subprocess.run(
82
- ["git", "rev-parse", "--short", "HEAD"],
83
- cwd=str(project_root),
84
- capture_output=True,
85
- text=True,
86
- timeout=10,
87
- check=False,
88
- )
89
- except (FileNotFoundError, subprocess.TimeoutExpired):
90
- return None
91
- if rev_result.returncode == 0:
92
- sha = (rev_result.stdout or "").strip()
93
- if sha:
94
- return f"detached:{sha}"
95
- return None
96
-
97
-
98
- def _detect_latest_active_vbrief(project_root: Path) -> str | None:
99
- """Return the most-recently-modified active vBRIEF as a POSIX relpath.
100
-
101
- Fail-open across OSError -- a vBRIEF whose ``stat()`` raises
102
- (TOCTOU delete between ``glob()`` and ``stat()``, permission
103
- denied, broken symlink) is skipped rather than crashing the
104
- ritual. Returns ``None`` when no readable candidate survives.
105
- """
106
- active_dir = project_root / "vbrief" / "active"
107
- try:
108
- if not active_dir.is_dir():
109
- return None
110
- except OSError:
111
- return None
112
- candidates: list[tuple[float, Path]] = []
113
- try:
114
- children = list(active_dir.glob("*.vbrief.json"))
115
- except OSError:
116
- return None
117
- for child in children:
118
- try:
119
- if not child.is_file():
120
- continue
121
- mtime = child.stat().st_mtime
122
- except OSError:
123
- # Race with another process deleting the file between glob
124
- # and stat, or permission denied on a specific entry. Skip.
125
- continue
126
- candidates.append((mtime, child))
127
- if not candidates:
128
- return None
129
- candidates.sort(key=lambda pair: pair[0], reverse=True)
130
- latest = candidates[0][1]
131
- try:
132
- return latest.relative_to(project_root).as_posix()
133
- except ValueError:
134
- return None
135
-
136
-
137
- def _build_arg_parser() -> argparse.ArgumentParser:
138
- parser = argparse.ArgumentParser(
139
- prog="_session_start_hook",
140
- description="Write the session-start ritual sentinel (#1269).",
141
- )
142
- parser.add_argument(
143
- "--write",
144
- action="store_true",
145
- help="Write .deft/last-session.json from the current git state.",
146
- )
147
- parser.add_argument(
148
- "--project-root",
149
- type=Path,
150
- default=None,
151
- help="Project root directory (default: current working directory).",
152
- )
153
- return parser
154
-
155
-
156
- def main(argv: list[str] | None = None) -> int:
157
- args = _build_arg_parser().parse_args(argv)
158
- if not args.write:
159
- # No-op invocation; print the usage hint to stderr and return 0
160
- # so the ritual orchestration is never broken by a missing flag.
161
- sys.stderr.write(
162
- "_session_start_hook.py: pass --write to persist the sentinel.\n"
163
- )
164
- return 0
165
- project_root: Path = (args.project_root or Path(os.getcwd())).resolve()
166
- branch = _detect_branch(project_root)
167
- if not branch:
168
- sys.stderr.write(
169
- "_session_start_hook.py: could not determine current git branch; "
170
- "skipping sentinel write.\n"
171
- )
172
- return 2
173
- last_active = _detect_latest_active_vbrief(project_root)
174
- if not last_active:
175
- sys.stderr.write(
176
- "_session_start_hook.py: no active vBRIEF found under "
177
- "vbrief/active/; skipping sentinel write.\n"
178
- )
179
- return 2
180
- try:
181
- deft_version = resolve_version.resolve_version()
182
- except Exception as exc: # noqa: BLE001 -- best-effort
183
- sys.stderr.write(
184
- f"_session_start_hook.py: resolve_version failed: {exc}; "
185
- "skipping sentinel write.\n"
186
- )
187
- return 2
188
- try:
189
- sentinel_path = ritual_sentinel.write(
190
- project_root,
191
- deft_version=deft_version,
192
- last_active_vbrief=last_active,
193
- last_branch=branch,
194
- )
195
- except Exception as exc: # noqa: BLE001 -- surface to caller
196
- sys.stderr.write(
197
- f"_session_start_hook.py: sentinel write failed: {exc}\n"
198
- )
199
- return 1
200
- sys.stdout.write(f"{sentinel_path}\n")
201
- return 0
202
-
203
-
204
- if __name__ == "__main__":
205
- sys.exit(main(sys.argv[1:]))
@@ -1,365 +0,0 @@
1
- """Diff scanner for the system-of-record architecture gate."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- import subprocess
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- from preflight_architecture_sor import (
11
- LOW_RISK_PATH_PREFIXES,
12
- LOW_RISK_SUFFIXES,
13
- SCANNER_EXEMPT_PATHS,
14
- DetectedSignal,
15
- GateFinding,
16
- GateResult,
17
- _format_failure,
18
- _load_json_file,
19
- _system_of_record,
20
- validate_record,
21
- )
22
-
23
-
24
- def _is_low_risk_path(path: str) -> bool:
25
- clean = path.lstrip("./")
26
- if clean in SCANNER_EXEMPT_PATHS:
27
- return True
28
- if Path(clean).suffix.lower() in LOW_RISK_SUFFIXES:
29
- return True
30
- return clean.startswith(LOW_RISK_PATH_PREFIXES)
31
-
32
-
33
- def _storage_from_line(path: str, line: str) -> str:
34
- text = f"{path} {line}".lower()
35
- if re.search(r"\.ya?ml\b", text):
36
- return "yaml_file"
37
- if re.search(r"\.toml\b", text):
38
- return "toml_file"
39
- if re.search(r"\.(sqlite|sqlite3|db)\b", text):
40
- return "sqlite_file"
41
- if re.search(r"\.json\b", text):
42
- return "json_file"
43
- return "filesystem"
44
-
45
-
46
- def _path_name_signal(path: str) -> DetectedSignal | None:
47
- if _is_low_risk_path(path):
48
- return None
49
- name = Path(path).name.lower()
50
- if re.search(r"(registry|repository|store|manager|service)", name) and re.search(
51
- r"\.(py|js|jsx|ts|tsx|go|rb|java|kt)$", name
52
- ):
53
- return DetectedSignal(
54
- kind="state_module",
55
- path=path,
56
- line=None,
57
- detail="stateful module name",
58
- )
59
- if re.search(r"migrations?/", path) or re.search(r"\bmigration", name):
60
- return DetectedSignal(
61
- kind="database_model",
62
- path=path,
63
- line=None,
64
- detail="database migration path",
65
- storage="database",
66
- )
67
- return None
68
-
69
-
70
- def _looks_like_workflow_state_change(stripped: str) -> bool:
71
- term = (
72
- r"(workflow|workflows|job|jobs|queue|queues|runtime|orchestration|job_queue|"
73
- r"workflow_queue|runtime_state|orchestration_state|worker_state|run_state)"
74
- )
75
- action = (
76
- r"(create|schedule|enqueue|dequeue|start|complete|fail|cancel|retry|update|delete|"
77
- r"upsert|persist|save|load|restore|claim|lease|dispatch)"
78
- )
79
-
80
- if re.match(r"(#|//|/\*|\*)", stripped):
81
- return False
82
-
83
- patterns = (
84
- rf"\b(def|function|func)\s+({action}_{term}|{term}_{action})\b",
85
- r"\b(class|type)\s+\w*"
86
- r"(Workflow|Job|Queue|Runtime|Orchestration|WorkerState|RunState)\w*",
87
- rf"\b({term})\.(append|add|put|enqueue|dequeue|submit|dispatch|schedule|"
88
- rf"start|complete|fail|cancel|retry|update|delete|save|persist)\s*\(",
89
- rf"\b({term})\s*\[[^\]]+\]\s*=",
90
- rf"\b({term})\s*=\s*(new\s+Map\(|\{{\}}|\[\])",
91
- rf"\b({action}_{term}|{term}_{action})\s*\(",
92
- )
93
- return any(re.search(pattern, stripped, flags=re.IGNORECASE) for pattern in patterns)
94
-
95
-
96
- def _line_signals(path: str, line_no: int | None, line: str) -> list[DetectedSignal]:
97
- if _is_low_risk_path(path):
98
- return []
99
-
100
- stripped = line.strip()
101
- signals: list[DetectedSignal] = []
102
-
103
- if re.search(
104
- r"(write_text|write_bytes|fs\.(writeFile|writeFileSync|appendFile|createWriteStream)|"
105
- r"Deno\.write(Text)?File|os\.WriteFile|ioutil\.WriteFile|Files\.write|"
106
- r"open\([^)]*,\s*['\"][^'\"]*[wax])",
107
- stripped,
108
- ):
109
- signals.append(
110
- DetectedSignal(
111
- kind="filesystem_write",
112
- path=path,
113
- line=line_no,
114
- detail=stripped,
115
- storage=_storage_from_line(path, stripped),
116
- )
117
- )
118
-
119
- if re.search(r"\b(localStorage|sessionStorage|indexedDB|caches\.open)\b", stripped):
120
- signals.append(
121
- DetectedSignal(
122
- kind="browser_storage",
123
- path=path,
124
- line=line_no,
125
- detail=stripped,
126
- storage="browser_storage",
127
- )
128
- )
129
-
130
- path_name = Path(path).name.lower()
131
- if re.search(r"(registry|repository|store|manager)", path_name) and re.search(
132
- r"(new\s+Map\(|=\s*\{\}\s*(#|//|$)|:\s*dict\[)",
133
- stripped,
134
- ):
135
- signals.append(
136
- DetectedSignal(
137
- kind="in_memory_state",
138
- path=path,
139
- line=line_no,
140
- detail=stripped,
141
- storage="in_memory",
142
- )
143
- )
144
-
145
- if re.search(
146
- r"(@\w+\.(post|put|patch|delete)\b|\b(router|app)\.(post|put|patch|delete)\s*\(|"
147
- r"\b(def|function|func)\s+(create|select|update|delete|upsert)_?)",
148
- stripped,
149
- flags=re.IGNORECASE,
150
- ):
151
- signals.append(
152
- DetectedSignal(
153
- kind="mutation_endpoint",
154
- path=path,
155
- line=line_no,
156
- detail=stripped,
157
- )
158
- )
159
-
160
- if re.search(
161
- r"(CREATE\s+TABLE|ALTER\s+TABLE|sqlalchemy|db\.Column|models\.Model|"
162
- r"prisma|typeorm|sequelize|ActiveRecord)",
163
- stripped,
164
- flags=re.IGNORECASE,
165
- ):
166
- signals.append(
167
- DetectedSignal(
168
- kind="database_model",
169
- path=path,
170
- line=line_no,
171
- detail=stripped,
172
- storage="database",
173
- )
174
- )
175
-
176
- if re.search(
177
- r"\b(auth|session|permission|membership|role|grant|tenant|organization)\b",
178
- stripped,
179
- flags=re.IGNORECASE,
180
- ):
181
- signals.append(
182
- DetectedSignal(
183
- kind="auth_state",
184
- path=path,
185
- line=line_no,
186
- detail=stripped,
187
- )
188
- )
189
-
190
- if _looks_like_workflow_state_change(stripped):
191
- signals.append(
192
- DetectedSignal(
193
- kind="workflow_state",
194
- path=path,
195
- line=line_no,
196
- detail=stripped,
197
- )
198
- )
199
-
200
- return signals
201
-
202
-
203
- def scan_diff(diff_text: str) -> tuple[list[DetectedSignal], list[str]]:
204
- """Scan unified diff text for suspicious stateful patterns."""
205
- signals: list[DetectedSignal] = []
206
- changed_paths: list[str] = []
207
- current_path: str | None = None
208
- new_line_no: int | None = None
209
-
210
- for raw_line in diff_text.splitlines():
211
- if raw_line.startswith("diff --git "):
212
- parts = raw_line.split()
213
- if len(parts) >= 4:
214
- candidate = parts[3]
215
- current_path = candidate[2:] if candidate.startswith("b/") else candidate
216
- if current_path not in changed_paths:
217
- changed_paths.append(current_path)
218
- path_signal = _path_name_signal(current_path)
219
- if path_signal is not None:
220
- signals.append(path_signal)
221
- new_line_no = None
222
- continue
223
-
224
- if raw_line.startswith("+++ "):
225
- target = raw_line[4:].strip()
226
- if target == "/dev/null":
227
- current_path = None
228
- continue
229
- current_path = target[2:] if target.startswith("b/") else target
230
- if current_path not in changed_paths:
231
- changed_paths.append(current_path)
232
- path_signal = _path_name_signal(current_path)
233
- if path_signal is not None:
234
- signals.append(path_signal)
235
- continue
236
-
237
- if raw_line.startswith("@@ "):
238
- match = re.search(r"\+(\d+)", raw_line)
239
- new_line_no = int(match.group(1)) if match else None
240
- continue
241
-
242
- if current_path is None:
243
- continue
244
-
245
- if raw_line.startswith("+") and not raw_line.startswith("+++"):
246
- line = raw_line[1:]
247
- signals.extend(_line_signals(current_path, new_line_no, line))
248
- if new_line_no is not None:
249
- new_line_no += 1
250
- elif raw_line.startswith("-") and not raw_line.startswith("---"):
251
- continue
252
- elif new_line_no is not None:
253
- new_line_no += 1
254
-
255
- return signals, changed_paths
256
-
257
-
258
- def _git_diff(project_root: Path, base_ref: str) -> tuple[str | None, GateResult | None]:
259
- try:
260
- proc = subprocess.run(
261
- ["git", "diff", "--unified=0", "--no-ext-diff", base_ref, "--"],
262
- cwd=str(project_root),
263
- capture_output=True,
264
- text=True,
265
- encoding="utf-8",
266
- errors="replace",
267
- check=False,
268
- timeout=30,
269
- )
270
- except (OSError, subprocess.TimeoutExpired) as exc:
271
- return None, GateResult(
272
- 2,
273
- f"system-of-record gate misconfigured: could not run git diff: {exc}",
274
- )
275
- if proc.returncode != 0:
276
- detail = proc.stderr.strip() or proc.stdout.strip() or f"git diff exited {proc.returncode}"
277
- return None, GateResult(
278
- 2,
279
- f"system-of-record gate misconfigured: could not diff against {base_ref}: {detail}",
280
- )
281
- return proc.stdout, None
282
-
283
-
284
- def _changed_story_records(
285
- project_root: Path,
286
- changed_paths: list[str],
287
- ) -> tuple[list[tuple[Path, dict[str, Any], dict[str, Any]]], GateResult | None]:
288
- records: list[tuple[Path, dict[str, Any], dict[str, Any]]] = []
289
- for rel in changed_paths:
290
- if not rel.endswith(".vbrief.json"):
291
- continue
292
- if not rel.startswith(("vbrief/active/", "vbrief/pending/", "vbrief/proposed/")):
293
- continue
294
- path = project_root / rel
295
- payload, error = _load_json_file(path)
296
- if error is not None:
297
- return [], error
298
- assert payload is not None
299
- record = _system_of_record(payload)
300
- if record is not None:
301
- records.append((path, payload, record))
302
- return records, None
303
-
304
-
305
- def evaluate_diff_text(
306
- diff_text: str,
307
- *,
308
- project_root: Path,
309
- story_path: Path | None = None,
310
- ) -> GateResult:
311
- signals, changed_paths = scan_diff(diff_text)
312
- if not signals:
313
- return GateResult(0, "OK system-of-record gate passed: no stateful diff signals detected.")
314
-
315
- payload: dict[str, Any] | None = None
316
- record: dict[str, Any] | None = None
317
-
318
- if story_path is not None:
319
- payload, error = _load_json_file(story_path)
320
- if error is not None:
321
- return error
322
- assert payload is not None
323
- record = _system_of_record(payload)
324
- else:
325
- records, error = _changed_story_records(project_root, changed_paths)
326
- if error is not None:
327
- return error
328
- if len(records) == 1:
329
- _, payload, record = records[0]
330
- elif len(records) > 1:
331
- return GateResult(
332
- 2,
333
- "system-of-record gate misconfigured: multiple changed vBRIEFs "
334
- "contain system-of-record records; pass --story-path.",
335
- )
336
-
337
- if record is None:
338
- finding = GateFinding(
339
- reason=(
340
- "Diff contains stateful persistence signals, but no matching "
341
- "architecture.systemOfRecord design record was supplied or changed."
342
- ),
343
- required_fix=(
344
- "Run `task architecture:sor-preflight -- --story-path <path>` "
345
- "after adding the design record, or pass --story-path to this diff gate."
346
- ),
347
- detected_storage=signals[0].storage,
348
- )
349
- return GateResult(1, _format_failure([finding]), (finding,))
350
-
351
- result = validate_record(record, story_payload=payload, signals=signals)
352
- if result.code == 0:
353
- return GateResult(
354
- 0,
355
- f"OK system-of-record gate passed: {len(signals)} stateful diff signal(s) matched.",
356
- )
357
- return result
358
-
359
-
360
- def evaluate_diff(project_root: Path, base_ref: str, story_path: Path | None = None) -> GateResult:
361
- diff_text, error = _git_diff(project_root, base_ref)
362
- if error is not None:
363
- return error
364
- assert diff_text is not None
365
- return evaluate_diff_text(diff_text, project_root=project_root, story_path=story_path)
@@ -1,59 +0,0 @@
1
- """_stdio_utf8.py -- Reconfigure sys.stdout / sys.stderr to UTF-8.
2
-
3
- Belt-and-suspenders guard for Python scripts under ``scripts/`` that emit
4
- non-ASCII characters (the ``-- / -> / x / !`` style symbols and unicode
5
- equivalents used for success / pending / error / warning markers).
6
-
7
- The PRIMARY mechanism for UTF-8 stdout in deft is ``PYTHONUTF8=1`` (set at
8
- the top level of ``Taskfile.yml`` and on every included task per #540). This
9
- module is the SECONDARY safeguard for three scenarios where the env var
10
- does not help:
11
-
12
- 1. Scripts invoked directly (``python scripts/foo.py``) without going
13
- through a ``task`` command.
14
- 2. Subprocess invocations where the parent process strips or overrides
15
- the environment.
16
- 3. Child Python processes on Windows where the locale-dependent default
17
- codec (cp1252 on US-English systems) would otherwise crash on the
18
- unicode glyphs printed by several scripts (#540).
19
-
20
- Usage::
21
-
22
- from _stdio_utf8 import reconfigure_stdio
23
- reconfigure_stdio()
24
-
25
- Call once at module top, before any ``print()``. Idempotent: safe to call
26
- more than once and safe on streams that are already UTF-8.
27
- """
28
-
29
- from __future__ import annotations
30
-
31
- import sys
32
-
33
-
34
- def reconfigure_stdio() -> None:
35
- """Force ``sys.stdout`` / ``sys.stderr`` to UTF-8 without error on failure.
36
-
37
- Python 3.7+ exposes ``reconfigure()`` on ``TextIOWrapper`` streams; for
38
- redirected / closed / custom streams that lack it, we silently leave
39
- encoding untouched. The PYTHONUTF8 env set in the task layer is the
40
- primary fix; this function exists to defend against invocations that
41
- bypass the task layer entirely.
42
- """
43
- for stream_name in ("stdout", "stderr"):
44
- stream = getattr(sys, stream_name, None)
45
- if stream is None:
46
- continue
47
- encoding = (getattr(stream, "encoding", "") or "").lower()
48
- if encoding in ("utf-8", "utf8"):
49
- continue
50
- reconfigure = getattr(stream, "reconfigure", None)
51
- if reconfigure is None:
52
- continue
53
- try:
54
- reconfigure(encoding="utf-8")
55
- except (AttributeError, OSError, ValueError):
56
- # Reconfigure can fail on streams that aren't TextIOWrapper
57
- # (e.g. pytest's capsys, subprocess-captured pipes). Silently
58
- # continue -- the env var path remains the primary defence.
59
- continue