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