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