@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,205 @@
|
|
|
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:]))
|
|
@@ -0,0 +1,365 @@
|
|
|
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)
|
|
@@ -0,0 +1,59 @@
|
|
|
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
|