@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,1144 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- vbrief_validate.py -- Validate the vBRIEF-centric document model.
4
-
5
- Replaces and extends spec_validate.py for the vBRIEF lifecycle folder model.
6
- Validates individual scope vBRIEFs, PROJECT-DEFINITION.vbrief.json, and
7
- cross-file consistency.
8
-
9
- Usage:
10
- uv run python scripts/vbrief_validate.py [--vbrief-dir <path>]
11
- [--strict-origin-types]
12
- [--warnings-as-errors]
13
-
14
- Exit codes:
15
- 0 -- valid (may have warnings); also valid with warnings unless
16
- --warnings-as-errors is set
17
- 1 -- validation errors found (or warnings with --warnings-as-errors)
18
- 2 -- usage error
19
-
20
- Story: #333 (RFC #309), #536 (validator CLI flags, schema-trusting D11),
21
- #533 (full v0.6 transition -- strict 0.6-only acceptance)
22
- """
23
-
24
- from __future__ import annotations
25
-
26
- import contextlib
27
- import json
28
- import re
29
- import sys
30
- from pathlib import Path
31
- from typing import Any
32
-
33
- # Ensure sibling scripts (`_event_detect`) are importable when this file is
34
- # run directly. Mirrors the pattern in scripts/migrate_vbrief.py.
35
- sys.path.insert(0, str(Path(__file__).resolve().parent))
36
-
37
- import _precutover as _precutover # noqa: E402
38
- from _precutover import is_current_generated_specification, is_deprecation_redirect # noqa: E402
39
-
40
- DEPRECATED_REDIRECT_SENTINEL = _precutover.DEPRECATED_REDIRECT_SENTINEL
41
- __all__ = ("DEPRECATED_REDIRECT_SENTINEL",)
42
-
43
-
44
- # #635: Detection-bound emit helper -- lazy-imported so an import-time
45
- # failure in ``scripts/_event_detect.py`` cannot break the validator's
46
- # ability to load. The events surface MUST NOT break the wrapped CLI;
47
- # importing at module level would let an import-time exception in the
48
- # helper take down ``task check``'s vbrief:validate gate before the
49
- # call-site ``contextlib.suppress`` could intervene (Greptile P1 on PR
50
- # #707 -- mirrors the lazy pattern in ``run::_emit_event_safe``).
51
- # Filename is intentionally distinct from the sibling vBRIEF's
52
- # ``scripts/_events.py`` (behavioral events) to avoid file-level merge
53
- # conflicts; post-merge consolidation may unify them under one name.
54
- def _emit_event(name: str, payload: dict[str, Any]) -> None:
55
- """Lazy-import scripts/_event_detect.emit and forward the call."""
56
- from _event_detect import emit # noqa: I001 -- intentional lazy import
57
-
58
- emit(name, payload)
59
-
60
-
61
- # ---------------------------------------------------------------------------
62
- # Constants
63
- # ---------------------------------------------------------------------------
64
-
65
- # v0.6 Status enum from the canonical schema
66
- # (https://github.com/deftai/vBRIEF/blob/master/schemas/vbrief-core-0.6.schema.json).
67
- VALID_STATUSES = frozenset(
68
- {
69
- "draft",
70
- "proposed",
71
- "approved",
72
- "pending",
73
- "running",
74
- "completed",
75
- "blocked",
76
- "failed",
77
- "cancelled",
78
- }
79
- )
80
-
81
- # Strict v0.6-only acceptance (#533). The canonical schema at
82
- # vbrief/schemas/vbrief-core.schema.json pins vBRIEFInfo.version to
83
- # const "0.6"; this validator rejects every other version. Pre-existing
84
- # v0.5 vBRIEFs are automatically bumped to v0.6 during ``task
85
- # migrate:vbrief`` (#571); operators who see the error should re-run
86
- # the migrator on the affected project.
87
- VALID_VBRIEF_VERSIONS = frozenset({"0.6"})
88
-
89
- # D13: status-to-folder mapping. v0.6 adds ``failed`` as a terminal status
90
- # (#533 / refinement skill Phase 4 ``task scope:fail``); it belongs in
91
- # ``completed/`` because the scope has reached a terminal state (#537).
92
- FOLDER_ALLOWED_STATUSES: dict[str, frozenset[str]] = {
93
- "proposed": frozenset({"draft", "proposed"}),
94
- "pending": frozenset({"approved", "pending"}),
95
- "active": frozenset({"running", "blocked"}),
96
- "completed": frozenset({"completed", "failed"}),
97
- "cancelled": frozenset({"cancelled"}),
98
- }
99
-
100
- LIFECYCLE_FOLDERS = tuple(FOLDER_ALLOWED_STATUSES.keys())
101
-
102
- # D7: filename convention YYYY-MM-DD-descriptive-slug.vbrief.json
103
- FILENAME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.vbrief\.json$")
104
-
105
- # D3: expected narrative keys for PROJECT-DEFINITION (per #506 D3).
106
- # Values are normalized (lowercase, whitespace collapsed) so both the
107
- # historic lowercase-space ``tech stack`` and the #506 D3 PascalCase
108
- # ``TechStack`` shapes satisfy the validator. Comparison normalizes the
109
- # candidate key the same way.
110
- PROJECT_DEF_EXPECTED_NARRATIVES = frozenset(
111
- {
112
- "overview",
113
- "techstack",
114
- }
115
- )
116
-
117
-
118
- def _normalize_narrative_key(key: str) -> str:
119
- """Normalize a narrative key for D3 comparison.
120
-
121
- Lowercases, strips whitespace, and collapses word separators so
122
- ``TechStack`` / ``Tech Stack`` / ``tech stack`` / ``tech-stack`` all
123
- compare equal to the canonical ``techstack`` key (#506 D3 / D5).
124
- Uses the module-level ``re`` already imported at the top of the file
125
- (PR #525 Greptile P2 review).
126
- """
127
- low = (key or "").lower()
128
- return re.sub(r"[\s_\-]+", "", low)
129
-
130
-
131
- def _item_source_path_uris(item: dict) -> list[str]:
132
- """Collect source URIs that identify a PROJECT-DEFINITION registry item."""
133
- # Registry status tracks the item's own source vBRIEF; child plan references
134
- # are allowed to move through the lifecycle independently.
135
- metadata = item.get("metadata")
136
- if isinstance(metadata, dict):
137
- source_path = metadata.get("source_path")
138
- if isinstance(source_path, str) and source_path:
139
- return [source_path]
140
- return []
141
-
142
-
143
- def _validate_project_registry_scope_status(
144
- item: dict,
145
- item_index: int,
146
- filepath: Path,
147
- vbrief_dir: Path,
148
- ) -> list[str]:
149
- """Validate PROJECT-DEFINITION item status against referenced scope status."""
150
- errors: list[str] = []
151
- item_status = item.get("status")
152
- if not isinstance(item_status, str):
153
- return errors
154
-
155
- resolved_root = vbrief_dir.resolve()
156
- for uri in _item_source_path_uris(item):
157
- if uri.startswith(("http://", "https://", "#")):
158
- continue
159
- scope_path = _resolve_ref_path(uri, vbrief_dir)
160
- if scope_path is None:
161
- continue
162
- if not scope_path.is_relative_to(resolved_root) or not scope_path.exists():
163
- continue
164
- try:
165
- scope_data = json.loads(scope_path.read_text(encoding="utf-8"))
166
- except (OSError, json.JSONDecodeError):
167
- continue
168
- scope_plan = scope_data.get("plan")
169
- if not isinstance(scope_plan, dict):
170
- continue
171
- scope_status = scope_plan.get("status")
172
- if isinstance(scope_status, str) and scope_status != item_status:
173
- errors.append(
174
- f"{filepath}: items[{item_index}] status is {item_status!r} "
175
- f"but referenced scope '{uri}' has plan.status {scope_status!r} "
176
- "(D3 registry-status)"
177
- )
178
- return errors
179
-
180
-
181
- # D11: origin reference type patterns.
182
- #
183
- # Default behavior (schema-trusting, Option A from #536): ANY reference whose
184
- # `type` matches `^x-vbrief/` counts as an origin. This matches the v0.6
185
- # schema pattern and aligns with the shape documented in
186
- # conventions/references.md and the refinement skill.
187
- #
188
- # Strict behavior (opt-in via --strict-origin-types): only the registered
189
- # allow-list below counts. Teams who want zero tolerance for ad-hoc
190
- # `x-vbrief/*` values pass --strict-origin-types in CI.
191
- ORIGIN_TYPE_PATTERN = re.compile(r"^x-vbrief/")
192
-
193
- STRICT_ORIGIN_ALLOWLIST = frozenset(
194
- {
195
- "x-vbrief/plan",
196
- "x-vbrief/github-issue",
197
- "x-vbrief/github-pr",
198
- "x-vbrief/jira-ticket",
199
- "x-vbrief/user-request",
200
- "x-vbrief/spec-section",
201
- }
202
- )
203
-
204
- # Legacy bare origin types accepted for backward compatibility with
205
- # pre-v0.20 vBRIEFs that pre-date the x-vbrief/* prefix convention.
206
- # These are accepted unconditionally (independent of --strict-origin-types)
207
- # so pre-migration vBRIEFs do not regress.
208
- LEGACY_ORIGIN_TYPES = frozenset(
209
- {
210
- "github-issue",
211
- "jira-ticket",
212
- "user-request",
213
- }
214
- )
215
-
216
- # Files that should contain the redirect sentinel after migration
217
- DEPRECATED_FILES = ("SPECIFICATION.md", "PROJECT.md")
218
-
219
-
220
- # ---------------------------------------------------------------------------
221
- # Schema validation (reuses spec_validate.py logic, extended)
222
- # ---------------------------------------------------------------------------
223
-
224
-
225
- def validate_vbrief_schema(data: dict, filepath: str) -> list[str]:
226
- """Validate vBRIEF structural requirements (v0.6). Returns errors.
227
-
228
- Strictly requires ``vBRIEFInfo.version == "0.6"`` to match the canonical
229
- v0.6 schema vendored at ``vbrief/schemas/vbrief-core.schema.json`` (#533).
230
- Any v0.5 vBRIEF is auto-bumped to v0.6 during ``task migrate:vbrief``
231
- (#571); operators who hit the error should re-run the migrator.
232
- """
233
- errors: list[str] = []
234
-
235
- # Top-level envelope
236
- if "vBRIEFInfo" not in data:
237
- errors.append(f"{filepath}: missing required top-level key 'vBRIEFInfo'")
238
- else:
239
- info = data["vBRIEFInfo"]
240
- if not isinstance(info, dict):
241
- errors.append(f"{filepath}: 'vBRIEFInfo' must be an object")
242
- elif info.get("version") not in VALID_VBRIEF_VERSIONS:
243
- # #571: replaced the non-existent "migrator sweep" recovery
244
- # pointer with the real command -- the migrator now auto-
245
- # bumps v0.5 -> v0.6 on every pre-existing
246
- # ``specification.vbrief.json`` / ``plan.vbrief.json`` it
247
- # encounters.
248
- errors.append(
249
- f"{filepath}: 'vBRIEFInfo.version' must be '0.6' "
250
- f"(canonical v0.6 schema, #533), got "
251
- f"{info.get('version')!r}. Run `task migrate:vbrief` to "
252
- f"upgrade pre-existing v0.5 vBRIEFs in-place."
253
- )
254
-
255
- if "plan" not in data:
256
- errors.append(f"{filepath}: missing required top-level key 'plan'")
257
- else:
258
- plan = data["plan"]
259
- if not isinstance(plan, dict):
260
- errors.append(f"{filepath}: 'plan' must be an object")
261
- else:
262
- for field in ("title", "status", "items"):
263
- if field not in plan:
264
- errors.append(f"{filepath}: 'plan' missing required field '{field}'")
265
-
266
- if "title" in plan and (not isinstance(plan["title"], str) or not plan["title"]):
267
- errors.append(f"{filepath}: 'plan.title' must be a non-empty string")
268
-
269
- if "status" in plan and plan["status"] not in VALID_STATUSES:
270
- errors.append(
271
- f"{filepath}: 'plan.status' invalid: {plan['status']!r} "
272
- f"(expected one of {sorted(VALID_STATUSES)})"
273
- )
274
-
275
- # Validate narratives values are strings
276
- if "narratives" in plan:
277
- _validate_narratives(plan["narratives"], f"{filepath}: plan.narratives", errors)
278
-
279
- if "items" in plan:
280
- if not isinstance(plan["items"], list):
281
- errors.append(f"{filepath}: 'plan.items' must be an array")
282
- else:
283
- for i, item in enumerate(plan["items"]):
284
- if not isinstance(item, dict):
285
- errors.append(f"{filepath}: plan.items[{i}] must be an object")
286
- continue
287
- _validate_plan_item(item, f"{filepath}: plan.items", errors)
288
-
289
- return errors
290
-
291
-
292
- def _validate_narratives(narratives: object, path: str, errors: list[str]) -> None:
293
- """Validate that all values in a narratives object are strings."""
294
- if not isinstance(narratives, dict):
295
- errors.append(f"{path} must be an object")
296
- return
297
- for key, value in narratives.items():
298
- if not isinstance(value, str):
299
- errors.append(f"{path}.{key} must be a string, got {type(value).__name__}")
300
-
301
-
302
- def _validate_plan_item(item: dict, path: str, errors: list[str]) -> None:
303
- """Recursively validate a PlanItem and its nested children.
304
-
305
- Per the canonical v0.6 schema, ``PlanItem.items`` is the PREFERRED
306
- nested field and ``PlanItem.subItems`` is the deprecated legacy alias
307
- kept for backward compatibility (#533 / Greptile P1). Both are accepted
308
- here and recursively validated; neither is treated as an error.
309
- """
310
- item_id = item.get("id", "<no-id>")
311
- item_path = f"{path}[{item_id}]"
312
-
313
- if "title" not in item:
314
- errors.append(f"{item_path} missing 'title'")
315
- if "status" not in item:
316
- errors.append(f"{item_path} missing 'status'")
317
- elif item["status"] not in VALID_STATUSES:
318
- errors.append(f"{item_path} invalid status: {item['status']!r}")
319
-
320
- if "narrative" in item:
321
- _validate_narratives(item["narrative"], f"{item_path}.narrative", errors)
322
-
323
- # v0.6 preferred nested field.
324
- if "items" in item:
325
- if not isinstance(item["items"], list):
326
- errors.append(f"{item_path}.items must be an array")
327
- else:
328
- for j, sub in enumerate(item["items"]):
329
- if not isinstance(sub, dict):
330
- errors.append(f"{item_path}.items[{j}] must be an object")
331
- continue
332
- _validate_plan_item(sub, f"{item_path}.items", errors)
333
-
334
- # Deprecated legacy alias -- still accepted for backward compatibility.
335
- if "subItems" in item:
336
- if not isinstance(item["subItems"], list):
337
- errors.append(f"{item_path}.subItems must be an array")
338
- else:
339
- for j, sub in enumerate(item["subItems"]):
340
- if not isinstance(sub, dict):
341
- errors.append(f"{item_path}.subItems[{j}] must be an object")
342
- continue
343
- _validate_plan_item(sub, f"{item_path}.subItems", errors)
344
-
345
-
346
- # ---------------------------------------------------------------------------
347
- # D7: Filename convention
348
- # ---------------------------------------------------------------------------
349
-
350
-
351
- def validate_filename(filepath: Path) -> list[str]:
352
- """Check filename matches YYYY-MM-DD-descriptive-slug.vbrief.json."""
353
- name = filepath.name
354
- if name == "PROJECT-DEFINITION.vbrief.json":
355
- return [] # PROJECT-DEFINITION has its own convention
356
- if not FILENAME_PATTERN.match(name):
357
- return [
358
- f"{filepath}: filename '{name}' does not match convention "
359
- "YYYY-MM-DD-descriptive-slug.vbrief.json (D7)"
360
- ]
361
- return []
362
-
363
-
364
- # ---------------------------------------------------------------------------
365
- # D2: Folder/status consistency
366
- # ---------------------------------------------------------------------------
367
-
368
-
369
- def validate_folder_status(filepath: Path, data: dict, vbrief_dir: Path) -> list[str]:
370
- """Verify plan.status matches the lifecycle folder the file is in."""
371
- errors: list[str] = []
372
- try:
373
- rel = filepath.relative_to(vbrief_dir)
374
- except ValueError:
375
- return []
376
-
377
- parts = rel.parts
378
- if len(parts) < 2:
379
- return [] # file is at vbrief/ root (e.g. PROJECT-DEFINITION)
380
-
381
- folder = parts[0]
382
- if folder not in FOLDER_ALLOWED_STATUSES:
383
- return [] # not in a lifecycle folder
384
-
385
- plan = data.get("plan", {})
386
- status = plan.get("status")
387
- if status is None:
388
- return [] # schema validator already catches missing status
389
-
390
- allowed = FOLDER_ALLOWED_STATUSES[folder]
391
- if status not in allowed:
392
- errors.append(
393
- f"{filepath}: plan.status is '{status}' but file is in "
394
- f"'{folder}/' (allowed statuses: {sorted(allowed)}) (D2)"
395
- )
396
-
397
- return errors
398
-
399
-
400
- # ---------------------------------------------------------------------------
401
- # D3: PROJECT-DEFINITION.vbrief.json validator
402
- # ---------------------------------------------------------------------------
403
-
404
-
405
- def validate_project_definition(filepath: Path, data: dict, vbrief_dir: Path) -> list[str]:
406
- """Validate PROJECT-DEFINITION.vbrief.json specific requirements."""
407
- errors: list[str] = []
408
- resolved_root = vbrief_dir.resolve()
409
-
410
- # Check narratives contains expected keys. Normalization collapses
411
- # word separators so both the historic ``tech stack`` spelling and the
412
- # #506 D3 canonical ``TechStack`` shape satisfy the check.
413
- plan = data.get("plan", {})
414
- narratives = plan.get("narratives", {})
415
- if isinstance(narratives, dict):
416
- present = {_normalize_narrative_key(k) for k in narratives}
417
- for expected in PROJECT_DEF_EXPECTED_NARRATIVES:
418
- if expected not in present:
419
- errors.append(f"{filepath}: narratives missing expected key '{expected}' (D3)")
420
-
421
- # #1131 (D12): typed plan.policy.triageScope[] validation -- helper
422
- # lives in scripts/triage_scope.py so this file does not grow.
423
- with contextlib.suppress(Exception):
424
- from triage_scope import validate_triage_scope_on_plan # noqa: I001
425
-
426
- errors.extend(validate_triage_scope_on_plan(plan, filepath))
427
-
428
- # #1133 (D14): typed plan.policy.triageScopeIgnores[] validation --
429
- # helper lives in scripts/_triage_scope_ignores.py and is re-exported
430
- # from triage_scope so the lazy-import hook pattern mirrors D12.
431
- with contextlib.suppress(Exception):
432
- from triage_scope import validate_triage_scope_ignores_on_plan # noqa: I001
433
-
434
- errors.extend(validate_triage_scope_ignores_on_plan(plan, filepath))
435
-
436
- # #1129 (D10): typed triageAutoClassify[] + triageHoldMarkers[] hooks.
437
- with contextlib.suppress(Exception):
438
- from triage_classify import (
439
- validate_triage_auto_classify_on_plan as _ac,
440
- validate_triage_hold_markers_on_plan as _hm,
441
- ) # noqa: I001,E501
442
-
443
- errors.extend(_ac(plan, filepath))
444
- errors.extend(_hm(plan, filepath))
445
-
446
- # #1128 (D11): typed plan.policy.triageRankingLabels[] validation --
447
- # helper lives in scripts/triage_queue.py so this file does not grow.
448
- with contextlib.suppress(Exception):
449
- from triage_queue import validate_triage_ranking_labels_on_plan # noqa: I001
450
-
451
- errors.extend(validate_triage_ranking_labels_on_plan(plan, filepath))
452
-
453
- # #1124 (D4): typed plan.policy.wipCap validation -- helper lives in
454
- # scripts/policy.py so this file does not grow. Mirrors the D10 /
455
- # D11 / D12 hook pattern above.
456
- with contextlib.suppress(Exception):
457
- from policy import validate_wip_cap_on_plan # noqa: I001
458
-
459
- errors.extend(validate_wip_cap_on_plan(plan, filepath))
460
-
461
- # #1348: typed plan.policy.sessionRitualStalenessHours validation.
462
- try:
463
- from policy import validate_session_ritual_staleness_hours_on_plan # noqa: I001
464
- except ImportError:
465
- pass
466
- else:
467
- errors.extend(validate_session_ritual_staleness_hours_on_plan(plan, filepath))
468
-
469
- # Check items registry entries reference existing scope vBRIEF files
470
- items = plan.get("items", [])
471
- if isinstance(items, list):
472
- for i, item in enumerate(items):
473
- if not isinstance(item, dict):
474
- continue
475
- errors.extend(
476
- _validate_project_registry_scope_status(item, i, filepath, vbrief_dir)
477
- )
478
- refs = item.get("references", [])
479
- if not isinstance(refs, list):
480
- refs = []
481
- for ref in refs:
482
- if not isinstance(ref, dict):
483
- continue
484
- uri = ref.get("uri", "")
485
- if uri and uri.startswith("file://"):
486
- ref_path = uri.replace("file://", "")
487
- full_path = (vbrief_dir / ref_path).resolve()
488
- if not full_path.is_relative_to(resolved_root):
489
- errors.append(
490
- f"{filepath}: items[{i}] references "
491
- f"'{ref_path}' outside vbrief directory (D3)"
492
- )
493
- continue
494
- if not full_path.exists():
495
- errors.append(
496
- f"{filepath}: items[{i}] references "
497
- f"'{ref_path}' which does not exist (D3)"
498
- )
499
- elif uri and not uri.startswith(("http://", "https://", "#")):
500
- # Treat as relative path
501
- full_path = (vbrief_dir / uri).resolve()
502
- if not full_path.is_relative_to(resolved_root):
503
- errors.append(
504
- f"{filepath}: items[{i}] references "
505
- f"'{uri}' outside vbrief directory (D3)"
506
- )
507
- continue
508
- if not full_path.exists():
509
- errors.append(
510
- f"{filepath}: items[{i}] references '{uri}' which does not exist (D3)"
511
- )
512
-
513
- return errors
514
-
515
-
516
- # ---------------------------------------------------------------------------
517
- # D4: Epic-story bidirectional link validation
518
- # ---------------------------------------------------------------------------
519
-
520
-
521
- def validate_epic_story_links(
522
- all_vbriefs: dict[Path, dict],
523
- vbrief_dir: Path,
524
- resolved_to_original: dict[Path, Path] | None = None,
525
- ) -> list[str]:
526
- """Validate bidirectional references between epic and story vBRIEFs."""
527
- errors: list[str] = []
528
- path_map = resolved_to_original or {}
529
-
530
- def _display(p: Path) -> str:
531
- """Return original path for display if available."""
532
- return str(path_map.get(p, p))
533
-
534
- for filepath, data in all_vbriefs.items():
535
- plan = data.get("plan", {})
536
- fp_display = _display(filepath)
537
-
538
- # Check forward references (epic -> children)
539
- refs = plan.get("references", [])
540
- if isinstance(refs, list):
541
- for ref in refs:
542
- if not isinstance(ref, dict):
543
- continue
544
- uri = ref.get("uri", "")
545
- ref_type = ref.get("type", "")
546
- if not uri or not ref_type:
547
- continue
548
- # D4 only applies to child plan references
549
- if ref_type != "x-vbrief/plan":
550
- continue
551
- # Resolve the child path
552
- child_path = _resolve_ref_path(uri, vbrief_dir)
553
- if child_path is None:
554
- continue
555
- if child_path not in all_vbriefs:
556
- if child_path.exists():
557
- continue # file exists but wasn't loaded
558
- errors.append(
559
- f"{fp_display}: references child '{uri}' which does not exist (D4)"
560
- )
561
- continue
562
- # Verify child has planRef back
563
- child_data = all_vbriefs[child_path]
564
- child_plan = child_data.get("plan", {})
565
- if not _has_plan_ref_to(child_plan, filepath, vbrief_dir):
566
- errors.append(
567
- f"{_display(child_path)}: missing planRef back "
568
- f"to parent '{filepath.name}' (D4)"
569
- )
570
-
571
- # Check backward references (story -> parent via planRef)
572
- # Scan both plan-level and item-level planRef values
573
- for plan_ref in _collect_plan_refs(plan):
574
- parent_path = _resolve_ref_path(plan_ref, vbrief_dir)
575
- if parent_path and parent_path in all_vbriefs:
576
- parent_data = all_vbriefs[parent_path]
577
- parent_plan = parent_data.get("plan", {})
578
- parent_refs = parent_plan.get("references", [])
579
- if isinstance(parent_refs, list):
580
- child_uris = set()
581
- for pref in parent_refs:
582
- if isinstance(pref, dict) and pref.get("type") == "x-vbrief/plan":
583
- child_uris.add(pref.get("uri", ""))
584
- if not _path_in_refs(filepath, child_uris, vbrief_dir):
585
- errors.append(
586
- f"{fp_display}: has planRef to "
587
- f"'{parent_path.name}' but parent "
588
- "does not list this file in "
589
- "references (D4)"
590
- )
591
- elif parent_path and not parent_path.exists():
592
- errors.append(
593
- f"{fp_display}: planRef references '{plan_ref}' which does not exist (D4)"
594
- )
595
-
596
- return errors
597
-
598
-
599
- def _collect_plan_refs(plan: dict) -> list[str]:
600
- """Collect all planRef values from plan root and top-level items.
601
-
602
- Note: subItems are intentionally not scanned -- planRef is only valid
603
- at the plan root and top-level item levels per vBRIEF convention.
604
- """
605
- refs: list[str] = []
606
- root_ref = plan.get("planRef")
607
- if isinstance(root_ref, str) and root_ref:
608
- refs.append(root_ref)
609
- for item in plan.get("items", []):
610
- if isinstance(item, dict):
611
- item_ref = item.get("planRef")
612
- if isinstance(item_ref, str) and item_ref:
613
- refs.append(item_ref)
614
- return refs
615
-
616
-
617
- def _resolve_ref_path(uri: str, vbrief_dir: Path) -> Path | None:
618
- """Resolve a reference URI to a filesystem path."""
619
- if not isinstance(uri, str):
620
- return None
621
- if uri.startswith("file://"):
622
- rel = uri.replace("file://", "")
623
- return (vbrief_dir / rel).resolve()
624
- if uri.startswith(("http://", "https://", "#")):
625
- return None
626
- # Treat as relative path
627
- return (vbrief_dir / uri).resolve()
628
-
629
-
630
- def _has_plan_ref_to(child_plan: dict, parent_path: Path, vbrief_dir: Path) -> bool:
631
- """Check if a plan has a planRef pointing back to parent_path."""
632
- plan_ref = child_plan.get("planRef")
633
- if plan_ref:
634
- resolved = _resolve_ref_path(plan_ref, vbrief_dir)
635
- if resolved and resolved == parent_path.resolve():
636
- return True
637
- # Also check items for planRef
638
- for item in child_plan.get("items", []):
639
- if isinstance(item, dict):
640
- item_ref = item.get("planRef")
641
- if item_ref:
642
- resolved = _resolve_ref_path(item_ref, vbrief_dir)
643
- if resolved and resolved == parent_path.resolve():
644
- return True
645
- return False
646
-
647
-
648
- def _path_in_refs(filepath: Path, uris: set[str], vbrief_dir: Path) -> bool:
649
- """Check if filepath is referenced by any URI in the set."""
650
- resolved_file = filepath.resolve()
651
- for uri in uris:
652
- resolved = _resolve_ref_path(uri, vbrief_dir)
653
- if resolved and resolved == resolved_file:
654
- return True
655
- return False
656
-
657
-
658
- # ---------------------------------------------------------------------------
659
- # D11: Origin provenance check
660
- # ---------------------------------------------------------------------------
661
-
662
-
663
- def validate_origin_provenance(
664
- filepath: Path,
665
- data: dict,
666
- vbrief_dir: Path,
667
- strict_origin_types: bool = False,
668
- ) -> list[str]:
669
- """Warn if a scope vBRIEF in pending/ or active/ has no origin reference.
670
-
671
- Default behavior (schema-trusting): ANY reference whose ``type`` matches
672
- ``^x-vbrief/`` counts as an origin. Legacy bare origin types
673
- (``github-issue``, ``jira-ticket``, ``user-request``) are also accepted
674
- unconditionally for pre-migration vBRIEFs (#536).
675
-
676
- Strict behavior (``strict_origin_types=True``): only values in
677
- :data:`STRICT_ORIGIN_ALLOWLIST` count. Legacy bare types continue to be
678
- accepted so pre-migration vBRIEFs do not regress.
679
- """
680
- warnings: list[str] = []
681
-
682
- try:
683
- rel = filepath.relative_to(vbrief_dir)
684
- except ValueError:
685
- return []
686
-
687
- parts = rel.parts
688
- if len(parts) < 2:
689
- return []
690
-
691
- folder = parts[0]
692
- if folder not in ("pending", "active"):
693
- return []
694
-
695
- plan = data.get("plan", {})
696
- refs = plan.get("references", [])
697
- has_origin = False
698
- if isinstance(refs, list):
699
- for ref in refs:
700
- if not isinstance(ref, dict):
701
- continue
702
- ref_type = ref.get("type", "")
703
- if not isinstance(ref_type, str):
704
- continue
705
-
706
- # Legacy bare origin types always count (pre-migration vBRIEFs).
707
- if ref_type in LEGACY_ORIGIN_TYPES:
708
- has_origin = True
709
- break
710
- # Legacy extended types (e.g. "github-issue-v2") also count for
711
- # backward compatibility with pre-v0.20 tooling.
712
- if any(
713
- ref_type.startswith((f"{legacy}-", f"{legacy}/")) for legacy in LEGACY_ORIGIN_TYPES
714
- ):
715
- has_origin = True
716
- break
717
-
718
- if strict_origin_types:
719
- # Allow-list mode: only registered x-vbrief/* values count.
720
- if ref_type in STRICT_ORIGIN_ALLOWLIST:
721
- has_origin = True
722
- break
723
- else:
724
- # Schema-trusting default: any x-vbrief/* value counts.
725
- if ORIGIN_TYPE_PATTERN.match(ref_type):
726
- has_origin = True
727
- break
728
-
729
- if not has_origin:
730
- if strict_origin_types:
731
- warnings.append(
732
- f"{filepath}: scope vBRIEF in '{folder}/' has no references "
733
- "with an allow-listed origin type (D11; "
734
- "--strict-origin-types)"
735
- )
736
- else:
737
- warnings.append(
738
- f"{filepath}: scope vBRIEF in '{folder}/' has no references "
739
- "with an origin type (D11)"
740
- )
741
-
742
- return warnings
743
-
744
-
745
- # ---------------------------------------------------------------------------
746
- # #398: Render staleness detection (PRD.md / SPECIFICATION.md)
747
- # ---------------------------------------------------------------------------
748
-
749
-
750
- def check_render_staleness(vbrief_dir: Path) -> list[str]:
751
- """Warn if PRD.md or SPECIFICATION.md are stale relative to specification.vbrief.json.
752
-
753
- Compares source narratives/items from specification.vbrief.json against
754
- the rendered export files. Returns warning strings for stale files.
755
- Skips silently if export files don't exist (#398).
756
- """
757
- warnings: list[str] = []
758
- project_root = vbrief_dir.parent
759
- spec_path = vbrief_dir / "specification.vbrief.json"
760
-
761
- if not spec_path.is_file():
762
- return warnings
763
-
764
- try:
765
- with open(spec_path, encoding="utf-8") as fh:
766
- data = json.load(fh)
767
- except (json.JSONDecodeError, OSError):
768
- return warnings
769
-
770
- plan = data.get("plan", {})
771
- if not isinstance(plan, dict):
772
- return warnings
773
-
774
- narratives = plan.get("narratives", {})
775
- items = plan.get("items", [])
776
- title = plan.get("title", "")
777
-
778
- # --- PRD.md ---
779
- prd_path = project_root / "PRD.md"
780
- if prd_path.is_file():
781
- warnings.extend(_check_prd_staleness(prd_path, narratives, title))
782
-
783
- # --- SPECIFICATION.md ---
784
- # Note: validate_deprecated_placeholders (called earlier in validate_all)
785
- # may also warn about SPECIFICATION.md if it lacks the deprecation redirect
786
- # sentinel. The staleness check here is complementary -- it fires for
787
- # rendered exports that have drifted, while the deprecated check fires for
788
- # files missing the redirect sentinel. Both can appear in the same run
789
- # during transitional states (e.g. a user ran `task spec:render` after
790
- # migration); this is intentional -- the deprecated warning takes priority
791
- # for the user's attention.
792
- spec_md_path = project_root / "SPECIFICATION.md"
793
- if spec_md_path.is_file():
794
- warnings.extend(_check_spec_staleness(spec_md_path, narratives, items, title))
795
-
796
- return warnings
797
-
798
-
799
- def _check_prd_staleness(
800
- prd_path: Path,
801
- narratives: dict,
802
- title: str,
803
- ) -> list[str]:
804
- """Return a warning if PRD.md does not reflect current source narratives."""
805
- try:
806
- content = prd_path.read_text(encoding="utf-8")
807
- except OSError:
808
- return []
809
-
810
- if not isinstance(narratives, dict) or not narratives:
811
- return []
812
-
813
- for value in narratives.values():
814
- if isinstance(value, str) and value.strip() and value.strip() not in content:
815
- return [
816
- "PRD.md may be stale relative to "
817
- "vbrief/specification.vbrief.json -- "
818
- "run `task prd:render` to refresh"
819
- ]
820
-
821
- if title and title not in content:
822
- return [
823
- "PRD.md may be stale relative to "
824
- "vbrief/specification.vbrief.json -- "
825
- "run `task prd:render` to refresh"
826
- ]
827
-
828
- return []
829
-
830
-
831
- def _check_spec_staleness(
832
- spec_md_path: Path,
833
- narratives: dict,
834
- items: list,
835
- title: str,
836
- ) -> list[str]:
837
- """Return a warning if SPECIFICATION.md does not reflect current source."""
838
- try:
839
- content = spec_md_path.read_text(encoding="utf-8")
840
- except OSError:
841
- return []
842
-
843
- # Skip deprecation redirects and current generated specification exports.
844
- project_root = spec_md_path.parent
845
- if is_deprecation_redirect(content) or is_current_generated_specification(
846
- project_root, content
847
- ):
848
- return []
849
-
850
- msg = (
851
- "SPECIFICATION.md may be stale relative to "
852
- "vbrief/specification.vbrief.json -- "
853
- "run `task spec:render` to refresh"
854
- )
855
-
856
- # Check item titles
857
- if isinstance(items, list):
858
- for item in items:
859
- if not isinstance(item, dict):
860
- continue
861
- item_title = item.get("title", "")
862
- if isinstance(item_title, str) and item_title and item_title not in content:
863
- return [msg]
864
-
865
- # Check all narrative values (mirrors _check_prd_staleness)
866
- if isinstance(narratives, dict):
867
- for value in narratives.values():
868
- if isinstance(value, str) and value.strip() and value.strip() not in content:
869
- return [msg]
870
-
871
- # Check title
872
- if title and title not in content:
873
- return [msg]
874
-
875
- return []
876
-
877
-
878
- # ---------------------------------------------------------------------------
879
- # Story S (#334): Post-migration placeholder integrity
880
- # ---------------------------------------------------------------------------
881
-
882
-
883
- def validate_deprecated_placeholders(
884
- vbrief_dir: Path,
885
- ) -> list[str]:
886
- """Check that SPECIFICATION.md and PROJECT.md contain the deprecation
887
- redirect sentinel if they exist.
888
-
889
- After migration, these files are replaced with redirect stubs containing
890
- ``<!-- deft:deprecated-redirect -->``. If a user or agent replaces the
891
- redirect with real content, flag it as a warning.
892
-
893
- Returns a list of warning strings.
894
- """
895
- warnings: list[str] = []
896
- project_root = vbrief_dir.parent
897
-
898
- for filename in DEPRECATED_FILES:
899
- filepath = project_root / filename
900
- if not filepath.is_file():
901
- continue
902
- try:
903
- content = filepath.read_text(encoding="utf-8")
904
- except OSError:
905
- continue
906
-
907
- if is_deprecation_redirect(content):
908
- continue
909
- if filename == "SPECIFICATION.md" and is_current_generated_specification(
910
- project_root, content
911
- ):
912
- continue
913
- warnings.append(
914
- f"{filename} contains non-redirect content -- "
915
- "this file is deprecated; use scope vBRIEFs "
916
- "in vbrief/ instead"
917
- )
918
-
919
- return warnings
920
-
921
-
922
- # ---------------------------------------------------------------------------
923
- # Main orchestrator
924
- # ---------------------------------------------------------------------------
925
-
926
-
927
- def load_vbrief(filepath: Path) -> tuple[dict | None, str | None]:
928
- """Load and parse a .vbrief.json file. Returns (data, error)."""
929
- try:
930
- with open(filepath, encoding="utf-8") as fh:
931
- data = json.load(fh)
932
- return data, None
933
- except json.JSONDecodeError as exc:
934
- return None, f"{filepath}: invalid JSON: {exc}"
935
- except OSError as exc:
936
- return None, f"{filepath}: cannot read: {exc}"
937
-
938
-
939
- def discover_vbriefs(vbrief_dir: Path) -> list[Path]:
940
- """Find all .vbrief.json files in lifecycle folders."""
941
- files: list[Path] = []
942
- for folder in LIFECYCLE_FOLDERS:
943
- folder_path = vbrief_dir / folder
944
- if folder_path.is_dir():
945
- files.extend(sorted(folder_path.glob("*.vbrief.json")))
946
- return files
947
-
948
-
949
- def _looks_like_decomposition_draft(data: object) -> bool:
950
- """Return whether root JSON has the temporary decomposition-draft shape."""
951
- if not isinstance(data, dict):
952
- return False
953
- stories = data.get("stories", data.get("children"))
954
- return isinstance(stories, list | dict)
955
-
956
-
957
- def validate_no_root_decomposition_drafts(vbrief_dir: Path) -> list[str]:
958
- """Reject decomposition draft proposals left at the workspace root."""
959
- project_root = vbrief_dir.parent
960
- errors: list[str] = []
961
- for path in sorted(project_root.glob("*.json")):
962
- try:
963
- data = json.loads(path.read_text(encoding="utf-8"))
964
- except (OSError, UnicodeDecodeError, json.JSONDecodeError):
965
- continue
966
- if _looks_like_decomposition_draft(data):
967
- errors.append(
968
- f"{path}: decomposition draft JSON must not live at workspace root; "
969
- "write temporary proposals under vbrief/.eval/decompositions/"
970
- )
971
- return errors
972
-
973
-
974
- def validate_all(
975
- vbrief_dir: Path,
976
- strict_origin_types: bool = False,
977
- ) -> tuple[list[str], list[str], int]:
978
- """Run all validators. Returns (errors, warnings, scope_count)."""
979
- errors: list[str] = []
980
- warnings: list[str] = []
981
- all_vbriefs: dict[Path, dict] = {}
982
- # Map resolved -> original path for consistent error messages
983
- resolved_to_original: dict[Path, Path] = {}
984
-
985
- # Discover scope vBRIEFs in lifecycle folders
986
- scope_files = discover_vbriefs(vbrief_dir)
987
- errors.extend(validate_no_root_decomposition_drafts(vbrief_dir))
988
-
989
- # Validate each scope vBRIEF
990
- for filepath in scope_files:
991
- data, load_err = load_vbrief(filepath)
992
- if load_err:
993
- errors.append(load_err)
994
- continue
995
-
996
- if data is None:
997
- continue
998
-
999
- resolved = filepath.resolve()
1000
- all_vbriefs[resolved] = data
1001
- resolved_to_original[resolved] = filepath
1002
-
1003
- # Schema validation
1004
- errors.extend(validate_vbrief_schema(data, str(filepath)))
1005
-
1006
- # Filename convention (D7)
1007
- errors.extend(validate_filename(filepath))
1008
-
1009
- # Folder/status consistency (D2)
1010
- errors.extend(validate_folder_status(filepath, data, vbrief_dir))
1011
-
1012
- # Origin provenance (D11) -- warnings only
1013
- warnings.extend(
1014
- validate_origin_provenance(
1015
- filepath,
1016
- data,
1017
- vbrief_dir,
1018
- strict_origin_types=strict_origin_types,
1019
- )
1020
- )
1021
-
1022
- # Validate PROJECT-DEFINITION.vbrief.json if it exists
1023
- project_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
1024
- if project_def.exists():
1025
- data, load_err = load_vbrief(project_def)
1026
- if load_err:
1027
- errors.append(load_err)
1028
- elif data is not None:
1029
- resolved_pd = project_def.resolve()
1030
- all_vbriefs[resolved_pd] = data
1031
- resolved_to_original[resolved_pd] = project_def
1032
- errors.extend(validate_vbrief_schema(data, str(project_def)))
1033
- errors.extend(validate_project_definition(project_def, data, vbrief_dir))
1034
-
1035
- # Epic-story bidirectional link validation (D4)
1036
- if all_vbriefs:
1037
- errors.extend(validate_epic_story_links(all_vbriefs, vbrief_dir, resolved_to_original))
1038
-
1039
- # Post-migration placeholder integrity (Story S #334)
1040
- warnings.extend(validate_deprecated_placeholders(vbrief_dir))
1041
-
1042
- # Render staleness check (#398)
1043
- warnings.extend(check_render_staleness(vbrief_dir))
1044
-
1045
- # #635: emit vbrief:invalid event when validation surfaced any issue.
1046
- # Existing CLI exit-code semantics are unchanged (handled by main()).
1047
- # Events surface MUST NOT break validation, so registry/IO failures
1048
- # are silently suppressed so existing CLIs remain stable.
1049
- if errors or warnings:
1050
- with contextlib.suppress(Exception):
1051
- _emit_event(
1052
- "vbrief:invalid",
1053
- {
1054
- "vbrief_dir": str(vbrief_dir.resolve()),
1055
- "error_count": len(errors),
1056
- "warning_count": len(warnings),
1057
- "errors": list(errors),
1058
- "warnings": list(warnings),
1059
- },
1060
- )
1061
-
1062
- return errors, warnings, len(scope_files)
1063
-
1064
-
1065
- USAGE = (
1066
- "Usage: vbrief_validate.py [--vbrief-dir <path>] [--strict-origin-types] [--warnings-as-errors]"
1067
- )
1068
-
1069
-
1070
- def main(argv: list[str] | None = None) -> int:
1071
- """CLI entry point.
1072
-
1073
- Exit codes (#536):
1074
- 0 -- no errors (warnings tolerated unless --warnings-as-errors is set)
1075
- 1 -- errors, or warnings when --warnings-as-errors is set
1076
- 2 -- usage error (unknown flag / missing argument)
1077
- """
1078
- vbrief_dir = Path("vbrief")
1079
- strict_origin_types = False
1080
- warnings_as_errors = False
1081
-
1082
- # Parse args
1083
- args = list(sys.argv[1:] if argv is None else argv)
1084
- i = 0
1085
- while i < len(args):
1086
- arg = args[i]
1087
- if arg == "--vbrief-dir" and i + 1 < len(args):
1088
- vbrief_dir = Path(args[i + 1])
1089
- i += 2
1090
- elif arg == "--strict-origin-types":
1091
- strict_origin_types = True
1092
- i += 1
1093
- elif arg == "--warnings-as-errors":
1094
- warnings_as_errors = True
1095
- i += 1
1096
- elif arg in ("-h", "--help"):
1097
- print(USAGE)
1098
- return 0
1099
- else:
1100
- print(f"Unknown argument: {arg}", file=sys.stderr)
1101
- print(USAGE, file=sys.stderr)
1102
- return 2
1103
-
1104
- if not vbrief_dir.is_dir():
1105
- # No vbrief directory -- nothing to validate, pass silently
1106
- print(f"OK: No vbrief directory at {vbrief_dir} -- skipping validation")
1107
- return 0
1108
-
1109
- errors, warnings, scope_count = validate_all(
1110
- vbrief_dir, strict_origin_types=strict_origin_types
1111
- )
1112
-
1113
- # Print warnings first, then errors
1114
- for w in warnings:
1115
- print(f"WARN: {w}")
1116
- for e in errors:
1117
- print(f"FAIL: {e}")
1118
-
1119
- # Determine exit code up-front so the summary banner reflects it.
1120
- warnings_escalated = bool(warnings) and warnings_as_errors
1121
- exit_code = 1 if errors or warnings_escalated else 0
1122
-
1123
- # Only emit the "OK" banner when we will actually exit 0 (#536 Defect 2).
1124
- if exit_code == 0:
1125
- project_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
1126
- parts = []
1127
- if scope_count:
1128
- parts.append(f"{scope_count} scope vBRIEF(s)")
1129
- if project_def.exists():
1130
- parts.append("PROJECT-DEFINITION")
1131
- summary = ", ".join(parts) if parts else "no vBRIEF files"
1132
- warning_note = f" ({len(warnings)} warning(s))" if warnings else ""
1133
- print(f"OK: vBRIEF validation passed: {summary}{warning_note}")
1134
- else:
1135
- if errors:
1136
- print(f"\nFAIL: {len(errors)} error(s) found")
1137
- if warnings_escalated and not errors:
1138
- print(f"\nFAIL: {len(warnings)} warning(s) treated as errors (--warnings-as-errors)")
1139
-
1140
- return exit_code
1141
-
1142
-
1143
- if __name__ == "__main__":
1144
- sys.exit(main())