@deftai/directive-content 0.55.1 → 0.56.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.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +13 -3
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +82 -11
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/skills/deft-directive-swarm/SKILL.md +7 -26
- package/skills/deft-directive-sync/SKILL.md +1 -1
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +2 -2
|
@@ -0,0 +1,1195 @@
|
|
|
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 _scope_ids_for_ref_uri(uri: str) -> set[str]:
|
|
132
|
+
"""Return possible PROJECT-DEFINITION registry IDs for a local scope URI."""
|
|
133
|
+
rel = uri[len("file://") :] if uri.startswith("file://") else uri
|
|
134
|
+
name = Path(rel).name
|
|
135
|
+
full_id = name[: -len(".vbrief.json")] if name.endswith(".vbrief.json") else Path(name).stem
|
|
136
|
+
ids = {full_id}
|
|
137
|
+
parts = full_id.split("-", 3)
|
|
138
|
+
if (
|
|
139
|
+
len(parts) == 4
|
|
140
|
+
and len(parts[0]) == 4
|
|
141
|
+
and len(parts[1]) == 2
|
|
142
|
+
and len(parts[2]) == 2
|
|
143
|
+
and all(part.isdigit() for part in parts[:3])
|
|
144
|
+
):
|
|
145
|
+
ids.add(parts[3])
|
|
146
|
+
return ids
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _item_local_scope_uris(item: dict, plan: dict) -> list[str]:
|
|
150
|
+
"""Collect local scope URIs that identify a PROJECT-DEFINITION registry item."""
|
|
151
|
+
uris: list[str] = []
|
|
152
|
+
|
|
153
|
+
metadata = item.get("metadata")
|
|
154
|
+
if isinstance(metadata, dict):
|
|
155
|
+
source_path = metadata.get("source_path")
|
|
156
|
+
if isinstance(source_path, str) and source_path:
|
|
157
|
+
uris.append(source_path)
|
|
158
|
+
metadata_refs = metadata.get("references")
|
|
159
|
+
if isinstance(metadata_refs, list):
|
|
160
|
+
for ref in metadata_refs:
|
|
161
|
+
if isinstance(ref, dict) and ref.get("type") == "x-vbrief/plan":
|
|
162
|
+
uri = ref.get("uri")
|
|
163
|
+
if isinstance(uri, str) and uri:
|
|
164
|
+
uris.append(uri)
|
|
165
|
+
|
|
166
|
+
refs = item.get("references")
|
|
167
|
+
if isinstance(refs, list):
|
|
168
|
+
for ref in refs:
|
|
169
|
+
if isinstance(ref, dict) and ref.get("type") == "x-vbrief/plan":
|
|
170
|
+
uri = ref.get("uri")
|
|
171
|
+
if isinstance(uri, str) and uri:
|
|
172
|
+
uris.append(uri)
|
|
173
|
+
|
|
174
|
+
item_id = item.get("id")
|
|
175
|
+
item_title = item.get("title")
|
|
176
|
+
plan_refs = plan.get("references")
|
|
177
|
+
if isinstance(plan_refs, list):
|
|
178
|
+
for ref in plan_refs:
|
|
179
|
+
if not isinstance(ref, dict) or ref.get("type") != "x-vbrief/plan":
|
|
180
|
+
continue
|
|
181
|
+
uri = ref.get("uri")
|
|
182
|
+
if not isinstance(uri, str) or not uri:
|
|
183
|
+
continue
|
|
184
|
+
title_matches = isinstance(item_title, str) and ref.get("title") == item_title
|
|
185
|
+
id_matches = isinstance(item_id, str) and item_id in _scope_ids_for_ref_uri(uri)
|
|
186
|
+
if title_matches or id_matches:
|
|
187
|
+
uris.append(uri)
|
|
188
|
+
|
|
189
|
+
# Preserve first-seen order while avoiding duplicate diagnostics.
|
|
190
|
+
return list(dict.fromkeys(uris))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _validate_project_registry_scope_status(
|
|
194
|
+
item: dict,
|
|
195
|
+
item_index: int,
|
|
196
|
+
plan: dict,
|
|
197
|
+
filepath: Path,
|
|
198
|
+
vbrief_dir: Path,
|
|
199
|
+
) -> list[str]:
|
|
200
|
+
"""Validate PROJECT-DEFINITION item status against referenced scope status."""
|
|
201
|
+
errors: list[str] = []
|
|
202
|
+
item_status = item.get("status")
|
|
203
|
+
if not isinstance(item_status, str):
|
|
204
|
+
return errors
|
|
205
|
+
|
|
206
|
+
resolved_root = vbrief_dir.resolve()
|
|
207
|
+
for uri in _item_local_scope_uris(item, plan):
|
|
208
|
+
if uri.startswith(("http://", "https://", "#")):
|
|
209
|
+
continue
|
|
210
|
+
scope_path = _resolve_ref_path(uri, vbrief_dir)
|
|
211
|
+
if scope_path is None:
|
|
212
|
+
continue
|
|
213
|
+
if not scope_path.is_relative_to(resolved_root) or not scope_path.exists():
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
scope_data = json.loads(scope_path.read_text(encoding="utf-8"))
|
|
217
|
+
except (OSError, json.JSONDecodeError):
|
|
218
|
+
continue
|
|
219
|
+
scope_plan = scope_data.get("plan")
|
|
220
|
+
if not isinstance(scope_plan, dict):
|
|
221
|
+
continue
|
|
222
|
+
scope_status = scope_plan.get("status")
|
|
223
|
+
if isinstance(scope_status, str) and scope_status != item_status:
|
|
224
|
+
errors.append(
|
|
225
|
+
f"{filepath}: items[{item_index}] status is {item_status!r} "
|
|
226
|
+
f"but referenced scope '{uri}' has plan.status {scope_status!r} "
|
|
227
|
+
"(D3 registry-status)"
|
|
228
|
+
)
|
|
229
|
+
return errors
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# D11: origin reference type patterns.
|
|
233
|
+
#
|
|
234
|
+
# Default behavior (schema-trusting, Option A from #536): ANY reference whose
|
|
235
|
+
# `type` matches `^x-vbrief/` counts as an origin. This matches the v0.6
|
|
236
|
+
# schema pattern and aligns with the shape documented in
|
|
237
|
+
# conventions/references.md and the refinement skill.
|
|
238
|
+
#
|
|
239
|
+
# Strict behavior (opt-in via --strict-origin-types): only the registered
|
|
240
|
+
# allow-list below counts. Teams who want zero tolerance for ad-hoc
|
|
241
|
+
# `x-vbrief/*` values pass --strict-origin-types in CI.
|
|
242
|
+
ORIGIN_TYPE_PATTERN = re.compile(r"^x-vbrief/")
|
|
243
|
+
|
|
244
|
+
STRICT_ORIGIN_ALLOWLIST = frozenset(
|
|
245
|
+
{
|
|
246
|
+
"x-vbrief/plan",
|
|
247
|
+
"x-vbrief/github-issue",
|
|
248
|
+
"x-vbrief/github-pr",
|
|
249
|
+
"x-vbrief/jira-ticket",
|
|
250
|
+
"x-vbrief/user-request",
|
|
251
|
+
"x-vbrief/spec-section",
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Legacy bare origin types accepted for backward compatibility with
|
|
256
|
+
# pre-v0.20 vBRIEFs that pre-date the x-vbrief/* prefix convention.
|
|
257
|
+
# These are accepted unconditionally (independent of --strict-origin-types)
|
|
258
|
+
# so pre-migration vBRIEFs do not regress.
|
|
259
|
+
LEGACY_ORIGIN_TYPES = frozenset(
|
|
260
|
+
{
|
|
261
|
+
"github-issue",
|
|
262
|
+
"jira-ticket",
|
|
263
|
+
"user-request",
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Files that should contain the redirect sentinel after migration
|
|
268
|
+
DEPRECATED_FILES = ("SPECIFICATION.md", "PROJECT.md")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
# Schema validation (reuses spec_validate.py logic, extended)
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def validate_vbrief_schema(data: dict, filepath: str) -> list[str]:
|
|
277
|
+
"""Validate vBRIEF structural requirements (v0.6). Returns errors.
|
|
278
|
+
|
|
279
|
+
Strictly requires ``vBRIEFInfo.version == "0.6"`` to match the canonical
|
|
280
|
+
v0.6 schema vendored at ``vbrief/schemas/vbrief-core.schema.json`` (#533).
|
|
281
|
+
Any v0.5 vBRIEF is auto-bumped to v0.6 during ``task migrate:vbrief``
|
|
282
|
+
(#571); operators who hit the error should re-run the migrator.
|
|
283
|
+
"""
|
|
284
|
+
errors: list[str] = []
|
|
285
|
+
|
|
286
|
+
# Top-level envelope
|
|
287
|
+
if "vBRIEFInfo" not in data:
|
|
288
|
+
errors.append(f"{filepath}: missing required top-level key 'vBRIEFInfo'")
|
|
289
|
+
else:
|
|
290
|
+
info = data["vBRIEFInfo"]
|
|
291
|
+
if not isinstance(info, dict):
|
|
292
|
+
errors.append(f"{filepath}: 'vBRIEFInfo' must be an object")
|
|
293
|
+
elif info.get("version") not in VALID_VBRIEF_VERSIONS:
|
|
294
|
+
# #571: replaced the non-existent "migrator sweep" recovery
|
|
295
|
+
# pointer with the real command -- the migrator now auto-
|
|
296
|
+
# bumps v0.5 -> v0.6 on every pre-existing
|
|
297
|
+
# ``specification.vbrief.json`` / ``plan.vbrief.json`` it
|
|
298
|
+
# encounters.
|
|
299
|
+
errors.append(
|
|
300
|
+
f"{filepath}: 'vBRIEFInfo.version' must be '0.6' "
|
|
301
|
+
f"(canonical v0.6 schema, #533), got "
|
|
302
|
+
f"{info.get('version')!r}. Run `task migrate:vbrief` to "
|
|
303
|
+
f"upgrade pre-existing v0.5 vBRIEFs in-place."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
if "plan" not in data:
|
|
307
|
+
errors.append(f"{filepath}: missing required top-level key 'plan'")
|
|
308
|
+
else:
|
|
309
|
+
plan = data["plan"]
|
|
310
|
+
if not isinstance(plan, dict):
|
|
311
|
+
errors.append(f"{filepath}: 'plan' must be an object")
|
|
312
|
+
else:
|
|
313
|
+
for field in ("title", "status", "items"):
|
|
314
|
+
if field not in plan:
|
|
315
|
+
errors.append(f"{filepath}: 'plan' missing required field '{field}'")
|
|
316
|
+
|
|
317
|
+
if "title" in plan and (not isinstance(plan["title"], str) or not plan["title"]):
|
|
318
|
+
errors.append(f"{filepath}: 'plan.title' must be a non-empty string")
|
|
319
|
+
|
|
320
|
+
if "status" in plan and plan["status"] not in VALID_STATUSES:
|
|
321
|
+
errors.append(
|
|
322
|
+
f"{filepath}: 'plan.status' invalid: {plan['status']!r} "
|
|
323
|
+
f"(expected one of {sorted(VALID_STATUSES)})"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Validate narratives values are strings
|
|
327
|
+
if "narratives" in plan:
|
|
328
|
+
_validate_narratives(plan["narratives"], f"{filepath}: plan.narratives", errors)
|
|
329
|
+
|
|
330
|
+
if "items" in plan:
|
|
331
|
+
if not isinstance(plan["items"], list):
|
|
332
|
+
errors.append(f"{filepath}: 'plan.items' must be an array")
|
|
333
|
+
else:
|
|
334
|
+
for i, item in enumerate(plan["items"]):
|
|
335
|
+
if not isinstance(item, dict):
|
|
336
|
+
errors.append(f"{filepath}: plan.items[{i}] must be an object")
|
|
337
|
+
continue
|
|
338
|
+
_validate_plan_item(item, f"{filepath}: plan.items", errors)
|
|
339
|
+
|
|
340
|
+
return errors
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _validate_narratives(narratives: object, path: str, errors: list[str]) -> None:
|
|
344
|
+
"""Validate that all values in a narratives object are strings."""
|
|
345
|
+
if not isinstance(narratives, dict):
|
|
346
|
+
errors.append(f"{path} must be an object")
|
|
347
|
+
return
|
|
348
|
+
for key, value in narratives.items():
|
|
349
|
+
if not isinstance(value, str):
|
|
350
|
+
errors.append(f"{path}.{key} must be a string, got {type(value).__name__}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _validate_plan_item(item: dict, path: str, errors: list[str]) -> None:
|
|
354
|
+
"""Recursively validate a PlanItem and its nested children.
|
|
355
|
+
|
|
356
|
+
Per the canonical v0.6 schema, ``PlanItem.items`` is the PREFERRED
|
|
357
|
+
nested field and ``PlanItem.subItems`` is the deprecated legacy alias
|
|
358
|
+
kept for backward compatibility (#533 / Greptile P1). Both are accepted
|
|
359
|
+
here and recursively validated; neither is treated as an error.
|
|
360
|
+
"""
|
|
361
|
+
item_id = item.get("id", "<no-id>")
|
|
362
|
+
item_path = f"{path}[{item_id}]"
|
|
363
|
+
|
|
364
|
+
if "title" not in item:
|
|
365
|
+
errors.append(f"{item_path} missing 'title'")
|
|
366
|
+
if "status" not in item:
|
|
367
|
+
errors.append(f"{item_path} missing 'status'")
|
|
368
|
+
elif item["status"] not in VALID_STATUSES:
|
|
369
|
+
errors.append(f"{item_path} invalid status: {item['status']!r}")
|
|
370
|
+
|
|
371
|
+
if "narrative" in item:
|
|
372
|
+
_validate_narratives(item["narrative"], f"{item_path}.narrative", errors)
|
|
373
|
+
|
|
374
|
+
# v0.6 preferred nested field.
|
|
375
|
+
if "items" in item:
|
|
376
|
+
if not isinstance(item["items"], list):
|
|
377
|
+
errors.append(f"{item_path}.items must be an array")
|
|
378
|
+
else:
|
|
379
|
+
for j, sub in enumerate(item["items"]):
|
|
380
|
+
if not isinstance(sub, dict):
|
|
381
|
+
errors.append(f"{item_path}.items[{j}] must be an object")
|
|
382
|
+
continue
|
|
383
|
+
_validate_plan_item(sub, f"{item_path}.items", errors)
|
|
384
|
+
|
|
385
|
+
# Deprecated legacy alias -- still accepted for backward compatibility.
|
|
386
|
+
if "subItems" in item:
|
|
387
|
+
if not isinstance(item["subItems"], list):
|
|
388
|
+
errors.append(f"{item_path}.subItems must be an array")
|
|
389
|
+
else:
|
|
390
|
+
for j, sub in enumerate(item["subItems"]):
|
|
391
|
+
if not isinstance(sub, dict):
|
|
392
|
+
errors.append(f"{item_path}.subItems[{j}] must be an object")
|
|
393
|
+
continue
|
|
394
|
+
_validate_plan_item(sub, f"{item_path}.subItems", errors)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# ---------------------------------------------------------------------------
|
|
398
|
+
# D7: Filename convention
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def validate_filename(filepath: Path) -> list[str]:
|
|
403
|
+
"""Check filename matches YYYY-MM-DD-descriptive-slug.vbrief.json."""
|
|
404
|
+
name = filepath.name
|
|
405
|
+
if name == "PROJECT-DEFINITION.vbrief.json":
|
|
406
|
+
return [] # PROJECT-DEFINITION has its own convention
|
|
407
|
+
if not FILENAME_PATTERN.match(name):
|
|
408
|
+
return [
|
|
409
|
+
f"{filepath}: filename '{name}' does not match convention "
|
|
410
|
+
"YYYY-MM-DD-descriptive-slug.vbrief.json (D7)"
|
|
411
|
+
]
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# D2: Folder/status consistency
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def validate_folder_status(filepath: Path, data: dict, vbrief_dir: Path) -> list[str]:
|
|
421
|
+
"""Verify plan.status matches the lifecycle folder the file is in."""
|
|
422
|
+
errors: list[str] = []
|
|
423
|
+
try:
|
|
424
|
+
rel = filepath.relative_to(vbrief_dir)
|
|
425
|
+
except ValueError:
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
parts = rel.parts
|
|
429
|
+
if len(parts) < 2:
|
|
430
|
+
return [] # file is at vbrief/ root (e.g. PROJECT-DEFINITION)
|
|
431
|
+
|
|
432
|
+
folder = parts[0]
|
|
433
|
+
if folder not in FOLDER_ALLOWED_STATUSES:
|
|
434
|
+
return [] # not in a lifecycle folder
|
|
435
|
+
|
|
436
|
+
plan = data.get("plan", {})
|
|
437
|
+
status = plan.get("status")
|
|
438
|
+
if status is None:
|
|
439
|
+
return [] # schema validator already catches missing status
|
|
440
|
+
|
|
441
|
+
allowed = FOLDER_ALLOWED_STATUSES[folder]
|
|
442
|
+
if status not in allowed:
|
|
443
|
+
errors.append(
|
|
444
|
+
f"{filepath}: plan.status is '{status}' but file is in "
|
|
445
|
+
f"'{folder}/' (allowed statuses: {sorted(allowed)}) (D2)"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
return errors
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ---------------------------------------------------------------------------
|
|
452
|
+
# D3: PROJECT-DEFINITION.vbrief.json validator
|
|
453
|
+
# ---------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def validate_project_definition(filepath: Path, data: dict, vbrief_dir: Path) -> list[str]:
|
|
457
|
+
"""Validate PROJECT-DEFINITION.vbrief.json specific requirements."""
|
|
458
|
+
errors: list[str] = []
|
|
459
|
+
resolved_root = vbrief_dir.resolve()
|
|
460
|
+
|
|
461
|
+
# Check narratives contains expected keys. Normalization collapses
|
|
462
|
+
# word separators so both the historic ``tech stack`` spelling and the
|
|
463
|
+
# #506 D3 canonical ``TechStack`` shape satisfy the check.
|
|
464
|
+
plan = data.get("plan", {})
|
|
465
|
+
narratives = plan.get("narratives", {})
|
|
466
|
+
if isinstance(narratives, dict):
|
|
467
|
+
present = {_normalize_narrative_key(k) for k in narratives}
|
|
468
|
+
for expected in PROJECT_DEF_EXPECTED_NARRATIVES:
|
|
469
|
+
if expected not in present:
|
|
470
|
+
errors.append(f"{filepath}: narratives missing expected key '{expected}' (D3)")
|
|
471
|
+
|
|
472
|
+
# #1131 (D12): typed plan.policy.triageScope[] validation -- helper
|
|
473
|
+
# lives in scripts/triage_scope.py so this file does not grow.
|
|
474
|
+
with contextlib.suppress(Exception):
|
|
475
|
+
from triage_scope import validate_triage_scope_on_plan # noqa: I001
|
|
476
|
+
|
|
477
|
+
errors.extend(validate_triage_scope_on_plan(plan, filepath))
|
|
478
|
+
|
|
479
|
+
# #1133 (D14): typed plan.policy.triageScopeIgnores[] validation --
|
|
480
|
+
# helper lives in scripts/_triage_scope_ignores.py and is re-exported
|
|
481
|
+
# from triage_scope so the lazy-import hook pattern mirrors D12.
|
|
482
|
+
with contextlib.suppress(Exception):
|
|
483
|
+
from triage_scope import validate_triage_scope_ignores_on_plan # noqa: I001
|
|
484
|
+
|
|
485
|
+
errors.extend(validate_triage_scope_ignores_on_plan(plan, filepath))
|
|
486
|
+
|
|
487
|
+
# #1129 (D10): typed triageAutoClassify[] + triageHoldMarkers[] hooks.
|
|
488
|
+
with contextlib.suppress(Exception):
|
|
489
|
+
from triage_classify import (
|
|
490
|
+
validate_triage_auto_classify_on_plan as _ac,
|
|
491
|
+
validate_triage_hold_markers_on_plan as _hm,
|
|
492
|
+
) # noqa: I001,E501
|
|
493
|
+
|
|
494
|
+
errors.extend(_ac(plan, filepath))
|
|
495
|
+
errors.extend(_hm(plan, filepath))
|
|
496
|
+
|
|
497
|
+
# #1128 (D11): typed plan.policy.triageRankingLabels[] validation --
|
|
498
|
+
# helper lives in scripts/triage_queue.py so this file does not grow.
|
|
499
|
+
with contextlib.suppress(Exception):
|
|
500
|
+
from triage_queue import validate_triage_ranking_labels_on_plan # noqa: I001
|
|
501
|
+
|
|
502
|
+
errors.extend(validate_triage_ranking_labels_on_plan(plan, filepath))
|
|
503
|
+
|
|
504
|
+
# #1124 (D4): typed plan.policy.wipCap validation -- helper lives in
|
|
505
|
+
# scripts/policy.py so this file does not grow. Mirrors the D10 /
|
|
506
|
+
# D11 / D12 hook pattern above.
|
|
507
|
+
with contextlib.suppress(Exception):
|
|
508
|
+
from policy import validate_wip_cap_on_plan # noqa: I001
|
|
509
|
+
|
|
510
|
+
errors.extend(validate_wip_cap_on_plan(plan, filepath))
|
|
511
|
+
|
|
512
|
+
# #1348: typed plan.policy.sessionRitualStalenessHours validation.
|
|
513
|
+
try:
|
|
514
|
+
from policy import validate_session_ritual_staleness_hours_on_plan # noqa: I001
|
|
515
|
+
except ImportError:
|
|
516
|
+
pass
|
|
517
|
+
else:
|
|
518
|
+
errors.extend(validate_session_ritual_staleness_hours_on_plan(plan, filepath))
|
|
519
|
+
|
|
520
|
+
# Check items registry entries reference existing scope vBRIEF files
|
|
521
|
+
items = plan.get("items", [])
|
|
522
|
+
if isinstance(items, list):
|
|
523
|
+
for i, item in enumerate(items):
|
|
524
|
+
if not isinstance(item, dict):
|
|
525
|
+
continue
|
|
526
|
+
errors.extend(
|
|
527
|
+
_validate_project_registry_scope_status(item, i, plan, filepath, vbrief_dir)
|
|
528
|
+
)
|
|
529
|
+
refs = item.get("references", [])
|
|
530
|
+
if not isinstance(refs, list):
|
|
531
|
+
refs = []
|
|
532
|
+
for ref in refs:
|
|
533
|
+
if not isinstance(ref, dict):
|
|
534
|
+
continue
|
|
535
|
+
uri = ref.get("uri", "")
|
|
536
|
+
if uri and uri.startswith("file://"):
|
|
537
|
+
ref_path = uri.replace("file://", "")
|
|
538
|
+
full_path = (vbrief_dir / ref_path).resolve()
|
|
539
|
+
if not full_path.is_relative_to(resolved_root):
|
|
540
|
+
errors.append(
|
|
541
|
+
f"{filepath}: items[{i}] references "
|
|
542
|
+
f"'{ref_path}' outside vbrief directory (D3)"
|
|
543
|
+
)
|
|
544
|
+
continue
|
|
545
|
+
if not full_path.exists():
|
|
546
|
+
errors.append(
|
|
547
|
+
f"{filepath}: items[{i}] references "
|
|
548
|
+
f"'{ref_path}' which does not exist (D3)"
|
|
549
|
+
)
|
|
550
|
+
elif uri and not uri.startswith(("http://", "https://", "#")):
|
|
551
|
+
# Treat as relative path
|
|
552
|
+
full_path = (vbrief_dir / uri).resolve()
|
|
553
|
+
if not full_path.is_relative_to(resolved_root):
|
|
554
|
+
errors.append(
|
|
555
|
+
f"{filepath}: items[{i}] references "
|
|
556
|
+
f"'{uri}' outside vbrief directory (D3)"
|
|
557
|
+
)
|
|
558
|
+
continue
|
|
559
|
+
if not full_path.exists():
|
|
560
|
+
errors.append(
|
|
561
|
+
f"{filepath}: items[{i}] references '{uri}' which does not exist (D3)"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return errors
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# D4: Epic-story bidirectional link validation
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def validate_epic_story_links(
|
|
573
|
+
all_vbriefs: dict[Path, dict],
|
|
574
|
+
vbrief_dir: Path,
|
|
575
|
+
resolved_to_original: dict[Path, Path] | None = None,
|
|
576
|
+
) -> list[str]:
|
|
577
|
+
"""Validate bidirectional references between epic and story vBRIEFs."""
|
|
578
|
+
errors: list[str] = []
|
|
579
|
+
path_map = resolved_to_original or {}
|
|
580
|
+
|
|
581
|
+
def _display(p: Path) -> str:
|
|
582
|
+
"""Return original path for display if available."""
|
|
583
|
+
return str(path_map.get(p, p))
|
|
584
|
+
|
|
585
|
+
for filepath, data in all_vbriefs.items():
|
|
586
|
+
plan = data.get("plan", {})
|
|
587
|
+
fp_display = _display(filepath)
|
|
588
|
+
|
|
589
|
+
# Check forward references (epic -> children)
|
|
590
|
+
refs = plan.get("references", [])
|
|
591
|
+
if isinstance(refs, list):
|
|
592
|
+
for ref in refs:
|
|
593
|
+
if not isinstance(ref, dict):
|
|
594
|
+
continue
|
|
595
|
+
uri = ref.get("uri", "")
|
|
596
|
+
ref_type = ref.get("type", "")
|
|
597
|
+
if not uri or not ref_type:
|
|
598
|
+
continue
|
|
599
|
+
# D4 only applies to child plan references
|
|
600
|
+
if ref_type != "x-vbrief/plan":
|
|
601
|
+
continue
|
|
602
|
+
# Resolve the child path
|
|
603
|
+
child_path = _resolve_ref_path(uri, vbrief_dir)
|
|
604
|
+
if child_path is None:
|
|
605
|
+
continue
|
|
606
|
+
if child_path not in all_vbriefs:
|
|
607
|
+
if child_path.exists():
|
|
608
|
+
continue # file exists but wasn't loaded
|
|
609
|
+
errors.append(
|
|
610
|
+
f"{fp_display}: references child '{uri}' which does not exist (D4)"
|
|
611
|
+
)
|
|
612
|
+
continue
|
|
613
|
+
# Verify child has planRef back
|
|
614
|
+
child_data = all_vbriefs[child_path]
|
|
615
|
+
child_plan = child_data.get("plan", {})
|
|
616
|
+
if not _has_plan_ref_to(child_plan, filepath, vbrief_dir):
|
|
617
|
+
errors.append(
|
|
618
|
+
f"{_display(child_path)}: missing planRef back "
|
|
619
|
+
f"to parent '{filepath.name}' (D4)"
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
# Check backward references (story -> parent via planRef)
|
|
623
|
+
# Scan both plan-level and item-level planRef values
|
|
624
|
+
for plan_ref in _collect_plan_refs(plan):
|
|
625
|
+
parent_path = _resolve_ref_path(plan_ref, vbrief_dir)
|
|
626
|
+
if parent_path and parent_path in all_vbriefs:
|
|
627
|
+
parent_data = all_vbriefs[parent_path]
|
|
628
|
+
parent_plan = parent_data.get("plan", {})
|
|
629
|
+
parent_refs = parent_plan.get("references", [])
|
|
630
|
+
if isinstance(parent_refs, list):
|
|
631
|
+
child_uris = set()
|
|
632
|
+
for pref in parent_refs:
|
|
633
|
+
if isinstance(pref, dict) and pref.get("type") == "x-vbrief/plan":
|
|
634
|
+
child_uris.add(pref.get("uri", ""))
|
|
635
|
+
if not _path_in_refs(filepath, child_uris, vbrief_dir):
|
|
636
|
+
errors.append(
|
|
637
|
+
f"{fp_display}: has planRef to "
|
|
638
|
+
f"'{parent_path.name}' but parent "
|
|
639
|
+
"does not list this file in "
|
|
640
|
+
"references (D4)"
|
|
641
|
+
)
|
|
642
|
+
elif parent_path and not parent_path.exists():
|
|
643
|
+
errors.append(
|
|
644
|
+
f"{fp_display}: planRef references '{plan_ref}' which does not exist (D4)"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
return errors
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _collect_plan_refs(plan: dict) -> list[str]:
|
|
651
|
+
"""Collect all planRef values from plan root and top-level items.
|
|
652
|
+
|
|
653
|
+
Note: subItems are intentionally not scanned -- planRef is only valid
|
|
654
|
+
at the plan root and top-level item levels per vBRIEF convention.
|
|
655
|
+
"""
|
|
656
|
+
refs: list[str] = []
|
|
657
|
+
root_ref = plan.get("planRef")
|
|
658
|
+
if isinstance(root_ref, str) and root_ref:
|
|
659
|
+
refs.append(root_ref)
|
|
660
|
+
for item in plan.get("items", []):
|
|
661
|
+
if isinstance(item, dict):
|
|
662
|
+
item_ref = item.get("planRef")
|
|
663
|
+
if isinstance(item_ref, str) and item_ref:
|
|
664
|
+
refs.append(item_ref)
|
|
665
|
+
return refs
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _resolve_ref_path(uri: str, vbrief_dir: Path) -> Path | None:
|
|
669
|
+
"""Resolve a reference URI to a filesystem path."""
|
|
670
|
+
if not isinstance(uri, str):
|
|
671
|
+
return None
|
|
672
|
+
if uri.startswith("file://"):
|
|
673
|
+
rel = uri.replace("file://", "")
|
|
674
|
+
return (vbrief_dir / rel).resolve()
|
|
675
|
+
if uri.startswith(("http://", "https://", "#")):
|
|
676
|
+
return None
|
|
677
|
+
# Treat as relative path
|
|
678
|
+
return (vbrief_dir / uri).resolve()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _has_plan_ref_to(child_plan: dict, parent_path: Path, vbrief_dir: Path) -> bool:
|
|
682
|
+
"""Check if a plan has a planRef pointing back to parent_path."""
|
|
683
|
+
plan_ref = child_plan.get("planRef")
|
|
684
|
+
if plan_ref:
|
|
685
|
+
resolved = _resolve_ref_path(plan_ref, vbrief_dir)
|
|
686
|
+
if resolved and resolved == parent_path.resolve():
|
|
687
|
+
return True
|
|
688
|
+
# Also check items for planRef
|
|
689
|
+
for item in child_plan.get("items", []):
|
|
690
|
+
if isinstance(item, dict):
|
|
691
|
+
item_ref = item.get("planRef")
|
|
692
|
+
if item_ref:
|
|
693
|
+
resolved = _resolve_ref_path(item_ref, vbrief_dir)
|
|
694
|
+
if resolved and resolved == parent_path.resolve():
|
|
695
|
+
return True
|
|
696
|
+
return False
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _path_in_refs(filepath: Path, uris: set[str], vbrief_dir: Path) -> bool:
|
|
700
|
+
"""Check if filepath is referenced by any URI in the set."""
|
|
701
|
+
resolved_file = filepath.resolve()
|
|
702
|
+
for uri in uris:
|
|
703
|
+
resolved = _resolve_ref_path(uri, vbrief_dir)
|
|
704
|
+
if resolved and resolved == resolved_file:
|
|
705
|
+
return True
|
|
706
|
+
return False
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
# D11: Origin provenance check
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def validate_origin_provenance(
|
|
715
|
+
filepath: Path,
|
|
716
|
+
data: dict,
|
|
717
|
+
vbrief_dir: Path,
|
|
718
|
+
strict_origin_types: bool = False,
|
|
719
|
+
) -> list[str]:
|
|
720
|
+
"""Warn if a scope vBRIEF in pending/ or active/ has no origin reference.
|
|
721
|
+
|
|
722
|
+
Default behavior (schema-trusting): ANY reference whose ``type`` matches
|
|
723
|
+
``^x-vbrief/`` counts as an origin. Legacy bare origin types
|
|
724
|
+
(``github-issue``, ``jira-ticket``, ``user-request``) are also accepted
|
|
725
|
+
unconditionally for pre-migration vBRIEFs (#536).
|
|
726
|
+
|
|
727
|
+
Strict behavior (``strict_origin_types=True``): only values in
|
|
728
|
+
:data:`STRICT_ORIGIN_ALLOWLIST` count. Legacy bare types continue to be
|
|
729
|
+
accepted so pre-migration vBRIEFs do not regress.
|
|
730
|
+
"""
|
|
731
|
+
warnings: list[str] = []
|
|
732
|
+
|
|
733
|
+
try:
|
|
734
|
+
rel = filepath.relative_to(vbrief_dir)
|
|
735
|
+
except ValueError:
|
|
736
|
+
return []
|
|
737
|
+
|
|
738
|
+
parts = rel.parts
|
|
739
|
+
if len(parts) < 2:
|
|
740
|
+
return []
|
|
741
|
+
|
|
742
|
+
folder = parts[0]
|
|
743
|
+
if folder not in ("pending", "active"):
|
|
744
|
+
return []
|
|
745
|
+
|
|
746
|
+
plan = data.get("plan", {})
|
|
747
|
+
refs = plan.get("references", [])
|
|
748
|
+
has_origin = False
|
|
749
|
+
if isinstance(refs, list):
|
|
750
|
+
for ref in refs:
|
|
751
|
+
if not isinstance(ref, dict):
|
|
752
|
+
continue
|
|
753
|
+
ref_type = ref.get("type", "")
|
|
754
|
+
if not isinstance(ref_type, str):
|
|
755
|
+
continue
|
|
756
|
+
|
|
757
|
+
# Legacy bare origin types always count (pre-migration vBRIEFs).
|
|
758
|
+
if ref_type in LEGACY_ORIGIN_TYPES:
|
|
759
|
+
has_origin = True
|
|
760
|
+
break
|
|
761
|
+
# Legacy extended types (e.g. "github-issue-v2") also count for
|
|
762
|
+
# backward compatibility with pre-v0.20 tooling.
|
|
763
|
+
if any(
|
|
764
|
+
ref_type.startswith((f"{legacy}-", f"{legacy}/")) for legacy in LEGACY_ORIGIN_TYPES
|
|
765
|
+
):
|
|
766
|
+
has_origin = True
|
|
767
|
+
break
|
|
768
|
+
|
|
769
|
+
if strict_origin_types:
|
|
770
|
+
# Allow-list mode: only registered x-vbrief/* values count.
|
|
771
|
+
if ref_type in STRICT_ORIGIN_ALLOWLIST:
|
|
772
|
+
has_origin = True
|
|
773
|
+
break
|
|
774
|
+
else:
|
|
775
|
+
# Schema-trusting default: any x-vbrief/* value counts.
|
|
776
|
+
if ORIGIN_TYPE_PATTERN.match(ref_type):
|
|
777
|
+
has_origin = True
|
|
778
|
+
break
|
|
779
|
+
|
|
780
|
+
if not has_origin:
|
|
781
|
+
if strict_origin_types:
|
|
782
|
+
warnings.append(
|
|
783
|
+
f"{filepath}: scope vBRIEF in '{folder}/' has no references "
|
|
784
|
+
"with an allow-listed origin type (D11; "
|
|
785
|
+
"--strict-origin-types)"
|
|
786
|
+
)
|
|
787
|
+
else:
|
|
788
|
+
warnings.append(
|
|
789
|
+
f"{filepath}: scope vBRIEF in '{folder}/' has no references "
|
|
790
|
+
"with an origin type (D11)"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
return warnings
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# ---------------------------------------------------------------------------
|
|
797
|
+
# #398: Render staleness detection (PRD.md / SPECIFICATION.md)
|
|
798
|
+
# ---------------------------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def check_render_staleness(vbrief_dir: Path) -> list[str]:
|
|
802
|
+
"""Warn if PRD.md or SPECIFICATION.md are stale relative to specification.vbrief.json.
|
|
803
|
+
|
|
804
|
+
Compares source narratives/items from specification.vbrief.json against
|
|
805
|
+
the rendered export files. Returns warning strings for stale files.
|
|
806
|
+
Skips silently if export files don't exist (#398).
|
|
807
|
+
"""
|
|
808
|
+
warnings: list[str] = []
|
|
809
|
+
project_root = vbrief_dir.parent
|
|
810
|
+
spec_path = vbrief_dir / "specification.vbrief.json"
|
|
811
|
+
|
|
812
|
+
if not spec_path.is_file():
|
|
813
|
+
return warnings
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
with open(spec_path, encoding="utf-8") as fh:
|
|
817
|
+
data = json.load(fh)
|
|
818
|
+
except (json.JSONDecodeError, OSError):
|
|
819
|
+
return warnings
|
|
820
|
+
|
|
821
|
+
plan = data.get("plan", {})
|
|
822
|
+
if not isinstance(plan, dict):
|
|
823
|
+
return warnings
|
|
824
|
+
|
|
825
|
+
narratives = plan.get("narratives", {})
|
|
826
|
+
items = plan.get("items", [])
|
|
827
|
+
title = plan.get("title", "")
|
|
828
|
+
|
|
829
|
+
# --- PRD.md ---
|
|
830
|
+
prd_path = project_root / "PRD.md"
|
|
831
|
+
if prd_path.is_file():
|
|
832
|
+
warnings.extend(_check_prd_staleness(prd_path, narratives, title))
|
|
833
|
+
|
|
834
|
+
# --- SPECIFICATION.md ---
|
|
835
|
+
# Note: validate_deprecated_placeholders (called earlier in validate_all)
|
|
836
|
+
# may also warn about SPECIFICATION.md if it lacks the deprecation redirect
|
|
837
|
+
# sentinel. The staleness check here is complementary -- it fires for
|
|
838
|
+
# rendered exports that have drifted, while the deprecated check fires for
|
|
839
|
+
# files missing the redirect sentinel. Both can appear in the same run
|
|
840
|
+
# during transitional states (e.g. a user ran `task spec:render` after
|
|
841
|
+
# migration); this is intentional -- the deprecated warning takes priority
|
|
842
|
+
# for the user's attention.
|
|
843
|
+
spec_md_path = project_root / "SPECIFICATION.md"
|
|
844
|
+
if spec_md_path.is_file():
|
|
845
|
+
warnings.extend(_check_spec_staleness(spec_md_path, narratives, items, title))
|
|
846
|
+
|
|
847
|
+
return warnings
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _check_prd_staleness(
|
|
851
|
+
prd_path: Path,
|
|
852
|
+
narratives: dict,
|
|
853
|
+
title: str,
|
|
854
|
+
) -> list[str]:
|
|
855
|
+
"""Return a warning if PRD.md does not reflect current source narratives."""
|
|
856
|
+
try:
|
|
857
|
+
content = prd_path.read_text(encoding="utf-8")
|
|
858
|
+
except OSError:
|
|
859
|
+
return []
|
|
860
|
+
|
|
861
|
+
if not isinstance(narratives, dict) or not narratives:
|
|
862
|
+
return []
|
|
863
|
+
|
|
864
|
+
for value in narratives.values():
|
|
865
|
+
if isinstance(value, str) and value.strip() and value.strip() not in content:
|
|
866
|
+
return [
|
|
867
|
+
"PRD.md may be stale relative to "
|
|
868
|
+
"vbrief/specification.vbrief.json -- "
|
|
869
|
+
"run `task prd:render` to refresh"
|
|
870
|
+
]
|
|
871
|
+
|
|
872
|
+
if title and title not in content:
|
|
873
|
+
return [
|
|
874
|
+
"PRD.md may be stale relative to "
|
|
875
|
+
"vbrief/specification.vbrief.json -- "
|
|
876
|
+
"run `task prd:render` to refresh"
|
|
877
|
+
]
|
|
878
|
+
|
|
879
|
+
return []
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _check_spec_staleness(
|
|
883
|
+
spec_md_path: Path,
|
|
884
|
+
narratives: dict,
|
|
885
|
+
items: list,
|
|
886
|
+
title: str,
|
|
887
|
+
) -> list[str]:
|
|
888
|
+
"""Return a warning if SPECIFICATION.md does not reflect current source."""
|
|
889
|
+
try:
|
|
890
|
+
content = spec_md_path.read_text(encoding="utf-8")
|
|
891
|
+
except OSError:
|
|
892
|
+
return []
|
|
893
|
+
|
|
894
|
+
# Skip deprecation redirects and current generated specification exports.
|
|
895
|
+
project_root = spec_md_path.parent
|
|
896
|
+
if is_deprecation_redirect(content) or is_current_generated_specification(
|
|
897
|
+
project_root, content
|
|
898
|
+
):
|
|
899
|
+
return []
|
|
900
|
+
|
|
901
|
+
msg = (
|
|
902
|
+
"SPECIFICATION.md may be stale relative to "
|
|
903
|
+
"vbrief/specification.vbrief.json -- "
|
|
904
|
+
"run `task spec:render` to refresh"
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
# Check item titles
|
|
908
|
+
if isinstance(items, list):
|
|
909
|
+
for item in items:
|
|
910
|
+
if not isinstance(item, dict):
|
|
911
|
+
continue
|
|
912
|
+
item_title = item.get("title", "")
|
|
913
|
+
if isinstance(item_title, str) and item_title and item_title not in content:
|
|
914
|
+
return [msg]
|
|
915
|
+
|
|
916
|
+
# Check all narrative values (mirrors _check_prd_staleness)
|
|
917
|
+
if isinstance(narratives, dict):
|
|
918
|
+
for value in narratives.values():
|
|
919
|
+
if isinstance(value, str) and value.strip() and value.strip() not in content:
|
|
920
|
+
return [msg]
|
|
921
|
+
|
|
922
|
+
# Check title
|
|
923
|
+
if title and title not in content:
|
|
924
|
+
return [msg]
|
|
925
|
+
|
|
926
|
+
return []
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
# ---------------------------------------------------------------------------
|
|
930
|
+
# Story S (#334): Post-migration placeholder integrity
|
|
931
|
+
# ---------------------------------------------------------------------------
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def validate_deprecated_placeholders(
|
|
935
|
+
vbrief_dir: Path,
|
|
936
|
+
) -> list[str]:
|
|
937
|
+
"""Check that SPECIFICATION.md and PROJECT.md contain the deprecation
|
|
938
|
+
redirect sentinel if they exist.
|
|
939
|
+
|
|
940
|
+
After migration, these files are replaced with redirect stubs containing
|
|
941
|
+
``<!-- deft:deprecated-redirect -->``. If a user or agent replaces the
|
|
942
|
+
redirect with real content, flag it as a warning.
|
|
943
|
+
|
|
944
|
+
Returns a list of warning strings.
|
|
945
|
+
"""
|
|
946
|
+
warnings: list[str] = []
|
|
947
|
+
project_root = vbrief_dir.parent
|
|
948
|
+
|
|
949
|
+
for filename in DEPRECATED_FILES:
|
|
950
|
+
filepath = project_root / filename
|
|
951
|
+
if not filepath.is_file():
|
|
952
|
+
continue
|
|
953
|
+
try:
|
|
954
|
+
content = filepath.read_text(encoding="utf-8")
|
|
955
|
+
except OSError:
|
|
956
|
+
continue
|
|
957
|
+
|
|
958
|
+
if is_deprecation_redirect(content):
|
|
959
|
+
continue
|
|
960
|
+
if filename == "SPECIFICATION.md" and is_current_generated_specification(
|
|
961
|
+
project_root, content
|
|
962
|
+
):
|
|
963
|
+
continue
|
|
964
|
+
warnings.append(
|
|
965
|
+
f"{filename} contains non-redirect content -- "
|
|
966
|
+
"this file is deprecated; use scope vBRIEFs "
|
|
967
|
+
"in vbrief/ instead"
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
return warnings
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
# ---------------------------------------------------------------------------
|
|
974
|
+
# Main orchestrator
|
|
975
|
+
# ---------------------------------------------------------------------------
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def load_vbrief(filepath: Path) -> tuple[dict | None, str | None]:
|
|
979
|
+
"""Load and parse a .vbrief.json file. Returns (data, error)."""
|
|
980
|
+
try:
|
|
981
|
+
with open(filepath, encoding="utf-8") as fh:
|
|
982
|
+
data = json.load(fh)
|
|
983
|
+
return data, None
|
|
984
|
+
except json.JSONDecodeError as exc:
|
|
985
|
+
return None, f"{filepath}: invalid JSON: {exc}"
|
|
986
|
+
except OSError as exc:
|
|
987
|
+
return None, f"{filepath}: cannot read: {exc}"
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def discover_vbriefs(vbrief_dir: Path) -> list[Path]:
|
|
991
|
+
"""Find all .vbrief.json files in lifecycle folders."""
|
|
992
|
+
files: list[Path] = []
|
|
993
|
+
for folder in LIFECYCLE_FOLDERS:
|
|
994
|
+
folder_path = vbrief_dir / folder
|
|
995
|
+
if folder_path.is_dir():
|
|
996
|
+
files.extend(sorted(folder_path.glob("*.vbrief.json")))
|
|
997
|
+
return files
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _looks_like_decomposition_draft(data: object) -> bool:
|
|
1001
|
+
"""Return whether root JSON has the temporary decomposition-draft shape."""
|
|
1002
|
+
if not isinstance(data, dict):
|
|
1003
|
+
return False
|
|
1004
|
+
stories = data.get("stories", data.get("children"))
|
|
1005
|
+
return isinstance(stories, list | dict)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def validate_no_root_decomposition_drafts(vbrief_dir: Path) -> list[str]:
|
|
1009
|
+
"""Reject decomposition draft proposals left at the workspace root."""
|
|
1010
|
+
project_root = vbrief_dir.parent
|
|
1011
|
+
errors: list[str] = []
|
|
1012
|
+
for path in sorted(project_root.glob("*.json")):
|
|
1013
|
+
try:
|
|
1014
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1015
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
1016
|
+
continue
|
|
1017
|
+
if _looks_like_decomposition_draft(data):
|
|
1018
|
+
errors.append(
|
|
1019
|
+
f"{path}: decomposition draft JSON must not live at workspace root; "
|
|
1020
|
+
"write temporary proposals under vbrief/.eval/decompositions/"
|
|
1021
|
+
)
|
|
1022
|
+
return errors
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def validate_all(
|
|
1026
|
+
vbrief_dir: Path,
|
|
1027
|
+
strict_origin_types: bool = False,
|
|
1028
|
+
) -> tuple[list[str], list[str], int]:
|
|
1029
|
+
"""Run all validators. Returns (errors, warnings, scope_count)."""
|
|
1030
|
+
errors: list[str] = []
|
|
1031
|
+
warnings: list[str] = []
|
|
1032
|
+
all_vbriefs: dict[Path, dict] = {}
|
|
1033
|
+
# Map resolved -> original path for consistent error messages
|
|
1034
|
+
resolved_to_original: dict[Path, Path] = {}
|
|
1035
|
+
|
|
1036
|
+
# Discover scope vBRIEFs in lifecycle folders
|
|
1037
|
+
scope_files = discover_vbriefs(vbrief_dir)
|
|
1038
|
+
errors.extend(validate_no_root_decomposition_drafts(vbrief_dir))
|
|
1039
|
+
|
|
1040
|
+
# Validate each scope vBRIEF
|
|
1041
|
+
for filepath in scope_files:
|
|
1042
|
+
data, load_err = load_vbrief(filepath)
|
|
1043
|
+
if load_err:
|
|
1044
|
+
errors.append(load_err)
|
|
1045
|
+
continue
|
|
1046
|
+
|
|
1047
|
+
if data is None:
|
|
1048
|
+
continue
|
|
1049
|
+
|
|
1050
|
+
resolved = filepath.resolve()
|
|
1051
|
+
all_vbriefs[resolved] = data
|
|
1052
|
+
resolved_to_original[resolved] = filepath
|
|
1053
|
+
|
|
1054
|
+
# Schema validation
|
|
1055
|
+
errors.extend(validate_vbrief_schema(data, str(filepath)))
|
|
1056
|
+
|
|
1057
|
+
# Filename convention (D7)
|
|
1058
|
+
errors.extend(validate_filename(filepath))
|
|
1059
|
+
|
|
1060
|
+
# Folder/status consistency (D2)
|
|
1061
|
+
errors.extend(validate_folder_status(filepath, data, vbrief_dir))
|
|
1062
|
+
|
|
1063
|
+
# Origin provenance (D11) -- warnings only
|
|
1064
|
+
warnings.extend(
|
|
1065
|
+
validate_origin_provenance(
|
|
1066
|
+
filepath,
|
|
1067
|
+
data,
|
|
1068
|
+
vbrief_dir,
|
|
1069
|
+
strict_origin_types=strict_origin_types,
|
|
1070
|
+
)
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
# Validate PROJECT-DEFINITION.vbrief.json if it exists
|
|
1074
|
+
project_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
|
|
1075
|
+
if project_def.exists():
|
|
1076
|
+
data, load_err = load_vbrief(project_def)
|
|
1077
|
+
if load_err:
|
|
1078
|
+
errors.append(load_err)
|
|
1079
|
+
elif data is not None:
|
|
1080
|
+
resolved_pd = project_def.resolve()
|
|
1081
|
+
all_vbriefs[resolved_pd] = data
|
|
1082
|
+
resolved_to_original[resolved_pd] = project_def
|
|
1083
|
+
errors.extend(validate_vbrief_schema(data, str(project_def)))
|
|
1084
|
+
errors.extend(validate_project_definition(project_def, data, vbrief_dir))
|
|
1085
|
+
|
|
1086
|
+
# Epic-story bidirectional link validation (D4)
|
|
1087
|
+
if all_vbriefs:
|
|
1088
|
+
errors.extend(validate_epic_story_links(all_vbriefs, vbrief_dir, resolved_to_original))
|
|
1089
|
+
|
|
1090
|
+
# Post-migration placeholder integrity (Story S #334)
|
|
1091
|
+
warnings.extend(validate_deprecated_placeholders(vbrief_dir))
|
|
1092
|
+
|
|
1093
|
+
# Render staleness check (#398)
|
|
1094
|
+
warnings.extend(check_render_staleness(vbrief_dir))
|
|
1095
|
+
|
|
1096
|
+
# #635: emit vbrief:invalid event when validation surfaced any issue.
|
|
1097
|
+
# Existing CLI exit-code semantics are unchanged (handled by main()).
|
|
1098
|
+
# Events surface MUST NOT break validation, so registry/IO failures
|
|
1099
|
+
# are silently suppressed so existing CLIs remain stable.
|
|
1100
|
+
if errors or warnings:
|
|
1101
|
+
with contextlib.suppress(Exception):
|
|
1102
|
+
_emit_event(
|
|
1103
|
+
"vbrief:invalid",
|
|
1104
|
+
{
|
|
1105
|
+
"vbrief_dir": str(vbrief_dir.resolve()),
|
|
1106
|
+
"error_count": len(errors),
|
|
1107
|
+
"warning_count": len(warnings),
|
|
1108
|
+
"errors": list(errors),
|
|
1109
|
+
"warnings": list(warnings),
|
|
1110
|
+
},
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
return errors, warnings, len(scope_files)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
USAGE = (
|
|
1117
|
+
"Usage: vbrief_validate.py [--vbrief-dir <path>] [--strict-origin-types] [--warnings-as-errors]"
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1122
|
+
"""CLI entry point.
|
|
1123
|
+
|
|
1124
|
+
Exit codes (#536):
|
|
1125
|
+
0 -- no errors (warnings tolerated unless --warnings-as-errors is set)
|
|
1126
|
+
1 -- errors, or warnings when --warnings-as-errors is set
|
|
1127
|
+
2 -- usage error (unknown flag / missing argument)
|
|
1128
|
+
"""
|
|
1129
|
+
vbrief_dir = Path("vbrief")
|
|
1130
|
+
strict_origin_types = False
|
|
1131
|
+
warnings_as_errors = False
|
|
1132
|
+
|
|
1133
|
+
# Parse args
|
|
1134
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
1135
|
+
i = 0
|
|
1136
|
+
while i < len(args):
|
|
1137
|
+
arg = args[i]
|
|
1138
|
+
if arg == "--vbrief-dir" and i + 1 < len(args):
|
|
1139
|
+
vbrief_dir = Path(args[i + 1])
|
|
1140
|
+
i += 2
|
|
1141
|
+
elif arg == "--strict-origin-types":
|
|
1142
|
+
strict_origin_types = True
|
|
1143
|
+
i += 1
|
|
1144
|
+
elif arg == "--warnings-as-errors":
|
|
1145
|
+
warnings_as_errors = True
|
|
1146
|
+
i += 1
|
|
1147
|
+
elif arg in ("-h", "--help"):
|
|
1148
|
+
print(USAGE)
|
|
1149
|
+
return 0
|
|
1150
|
+
else:
|
|
1151
|
+
print(f"Unknown argument: {arg}", file=sys.stderr)
|
|
1152
|
+
print(USAGE, file=sys.stderr)
|
|
1153
|
+
return 2
|
|
1154
|
+
|
|
1155
|
+
if not vbrief_dir.is_dir():
|
|
1156
|
+
# No vbrief directory -- nothing to validate, pass silently
|
|
1157
|
+
print(f"OK: No vbrief directory at {vbrief_dir} -- skipping validation")
|
|
1158
|
+
return 0
|
|
1159
|
+
|
|
1160
|
+
errors, warnings, scope_count = validate_all(
|
|
1161
|
+
vbrief_dir, strict_origin_types=strict_origin_types
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
# Print warnings first, then errors
|
|
1165
|
+
for w in warnings:
|
|
1166
|
+
print(f"WARN: {w}")
|
|
1167
|
+
for e in errors:
|
|
1168
|
+
print(f"FAIL: {e}")
|
|
1169
|
+
|
|
1170
|
+
# Determine exit code up-front so the summary banner reflects it.
|
|
1171
|
+
warnings_escalated = bool(warnings) and warnings_as_errors
|
|
1172
|
+
exit_code = 1 if errors or warnings_escalated else 0
|
|
1173
|
+
|
|
1174
|
+
# Only emit the "OK" banner when we will actually exit 0 (#536 Defect 2).
|
|
1175
|
+
if exit_code == 0:
|
|
1176
|
+
project_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
|
|
1177
|
+
parts = []
|
|
1178
|
+
if scope_count:
|
|
1179
|
+
parts.append(f"{scope_count} scope vBRIEF(s)")
|
|
1180
|
+
if project_def.exists():
|
|
1181
|
+
parts.append("PROJECT-DEFINITION")
|
|
1182
|
+
summary = ", ".join(parts) if parts else "no vBRIEF files"
|
|
1183
|
+
warning_note = f" ({len(warnings)} warning(s))" if warnings else ""
|
|
1184
|
+
print(f"OK: vBRIEF validation passed: {summary}{warning_note}")
|
|
1185
|
+
else:
|
|
1186
|
+
if errors:
|
|
1187
|
+
print(f"\nFAIL: {len(errors)} error(s) found")
|
|
1188
|
+
if warnings_escalated and not errors:
|
|
1189
|
+
print(f"\nFAIL: {len(warnings)} warning(s) treated as errors (--warnings-as-errors)")
|
|
1190
|
+
|
|
1191
|
+
return exit_code
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
if __name__ == "__main__":
|
|
1195
|
+
sys.exit(main())
|