reqledger 0.1.0__py3-none-any.whl
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.
- reqledger/__init__.py +17 -0
- reqledger/__main__.py +8 -0
- reqledger/_version.py +24 -0
- reqledger/cli.py +694 -0
- reqledger/config.py +217 -0
- reqledger/errors.py +61 -0
- reqledger/ids.py +104 -0
- reqledger/launcher.py +19 -0
- reqledger/manifest.py +116 -0
- reqledger/model.py +367 -0
- reqledger/parser.py +282 -0
- reqledger/py.typed +0 -0
- reqledger/review.py +779 -0
- reqledger/store.py +94 -0
- reqledger-0.1.0.dist-info/METADATA +255 -0
- reqledger-0.1.0.dist-info/RECORD +22 -0
- reqledger-0.1.0.dist-info/WHEEL +5 -0
- reqledger-0.1.0.dist-info/entry_points.txt +2 -0
- reqledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- reqledger-0.1.0.dist-info/scm_file_list.json +40 -0
- reqledger-0.1.0.dist-info/scm_version.json +8 -0
- reqledger-0.1.0.dist-info/top_level.txt +1 -0
reqledger/cli.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
"""ReqLedger command-line interface (Typer).
|
|
2
|
+
|
|
3
|
+
Commands: init, new, list, show, validate, index, link, review, export.
|
|
4
|
+
Global options: ``--version``, ``--config PATH``, ``--json``.
|
|
5
|
+
|
|
6
|
+
Exit codes follow the brief: 0 success, 1 validation errors, 2 usage/config
|
|
7
|
+
errors.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as _dt
|
|
13
|
+
import json as _json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from reqledger import ids as id_utils
|
|
20
|
+
from reqledger import manifest as manifest_mod
|
|
21
|
+
from reqledger import review as review_mod
|
|
22
|
+
from reqledger import store as store_mod
|
|
23
|
+
from reqledger.config import load_config
|
|
24
|
+
from reqledger.errors import (
|
|
25
|
+
DuplicateIdError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
ParseError,
|
|
28
|
+
ReqLedgerError,
|
|
29
|
+
)
|
|
30
|
+
from reqledger.model import ReqLedgerConfig, Requirement
|
|
31
|
+
from reqledger.parser import render_record_text, split_front_matter_text
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="reqledger",
|
|
35
|
+
help=("ReqLedger: durable record owner for requirements and acceptance criteria."),
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
add_completion=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Exit codes per brief.
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_VALIDATION = 1
|
|
43
|
+
EXIT_USAGE = 2
|
|
44
|
+
|
|
45
|
+
DEFAULT_README_BODY = """# Requirements
|
|
46
|
+
|
|
47
|
+
ReqLedger stores durable requirement records here as Markdown files with TOML
|
|
48
|
+
front matter. Records are the source of truth; the manifest is derived state.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
_DEFAULT_CONFIG_BODY = """\
|
|
52
|
+
schema_version = 1
|
|
53
|
+
|
|
54
|
+
[paths]
|
|
55
|
+
root = "requirements"
|
|
56
|
+
records_dir = "requirements/records"
|
|
57
|
+
manifest = "requirements/manifest.json"
|
|
58
|
+
reports_dir = "requirements/reports"
|
|
59
|
+
reports_state_dir = "requirements/reports/reqledger"
|
|
60
|
+
|
|
61
|
+
[ids]
|
|
62
|
+
requirement_prefix = "REQ"
|
|
63
|
+
criterion_prefix = "AC"
|
|
64
|
+
width = 4
|
|
65
|
+
|
|
66
|
+
[review]
|
|
67
|
+
draft_stale_days = 90
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Global option state (populated by the main callback).
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _State:
|
|
77
|
+
config: ReqLedgerConfig | None = None
|
|
78
|
+
config_arg: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_state = _State()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _utc_today() -> str:
|
|
85
|
+
return _dt.datetime.now(_dt.timezone.utc).date().isoformat()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _emit_json(payload: object) -> None:
|
|
89
|
+
typer.echo(_json.dumps(payload, indent=2, sort_keys=False, ensure_ascii=False))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _resolve_config(start: Path | None = None) -> ReqLedgerConfig:
|
|
93
|
+
# Always resolve fresh: each CLI invocation is a single process, and
|
|
94
|
+
# caching would leak state across CliRunner calls in tests.
|
|
95
|
+
return load_config(config=_state.config_arg, start=start or Path.cwd())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _config_error(code: int = EXIT_USAGE) -> typer.Exit:
|
|
99
|
+
raise typer.Exit(code=code)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _load_workspace_records(config: ReqLedgerConfig) -> list[Requirement]:
|
|
103
|
+
paths = store_mod.discover_records(config.records_dir)
|
|
104
|
+
records = store_mod.load_records(paths)
|
|
105
|
+
return records
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.callback(invoke_without_command=True)
|
|
109
|
+
def main_callback(
|
|
110
|
+
version: bool = typer.Option(
|
|
111
|
+
False,
|
|
112
|
+
"--version",
|
|
113
|
+
help="Show the ReqLedger version and exit.",
|
|
114
|
+
is_eager=True,
|
|
115
|
+
),
|
|
116
|
+
config: str | None = typer.Option(
|
|
117
|
+
None,
|
|
118
|
+
"--config",
|
|
119
|
+
help="Path to a reqledger.toml config file.",
|
|
120
|
+
metavar="PATH",
|
|
121
|
+
),
|
|
122
|
+
) -> None:
|
|
123
|
+
"""ReqLedger: durable record owner for requirements and acceptance criteria."""
|
|
124
|
+
from reqledger import __version__
|
|
125
|
+
|
|
126
|
+
_state.config_arg = config
|
|
127
|
+
if version:
|
|
128
|
+
typer.echo(__version__)
|
|
129
|
+
raise typer.Exit(code=EXIT_OK)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
# init
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("init")
|
|
138
|
+
def init_cmd(
|
|
139
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
|
|
140
|
+
json_output: bool = typer.Option(
|
|
141
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
142
|
+
),
|
|
143
|
+
) -> None:
|
|
144
|
+
"""Create the requirements workspace layout and default config."""
|
|
145
|
+
config = _resolve_config()
|
|
146
|
+
created: list[str] = []
|
|
147
|
+
existing: list[str] = []
|
|
148
|
+
skipped: list[str] = []
|
|
149
|
+
|
|
150
|
+
files: list[tuple[Path, str]] = []
|
|
151
|
+
config_dir = config.workspace_root
|
|
152
|
+
config_file = config_dir / "reqledger.toml"
|
|
153
|
+
files.append((config_file, _DEFAULT_CONFIG_BODY))
|
|
154
|
+
readme = config.root / "README.md"
|
|
155
|
+
files.append((readme, DEFAULT_README_BODY))
|
|
156
|
+
dirs = [
|
|
157
|
+
config.records_dir,
|
|
158
|
+
config.reports_dir,
|
|
159
|
+
config.reports_state_dir,
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
for path, content in files:
|
|
163
|
+
if path.exists() and not force:
|
|
164
|
+
existing.append(path.as_posix())
|
|
165
|
+
continue
|
|
166
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
path.write_text(content, encoding="utf-8")
|
|
168
|
+
created.append(path.as_posix())
|
|
169
|
+
|
|
170
|
+
for directory in dirs:
|
|
171
|
+
if directory.exists() and not force:
|
|
172
|
+
existing.append(directory.as_posix() + "/")
|
|
173
|
+
continue
|
|
174
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
created.append(directory.as_posix() + "/")
|
|
176
|
+
|
|
177
|
+
# records/ may equal reports/ root sibling; ensure records exists too.
|
|
178
|
+
if not config.records_dir.exists():
|
|
179
|
+
config.records_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
created.append(config.records_dir.as_posix() + "/")
|
|
181
|
+
|
|
182
|
+
if json_output:
|
|
183
|
+
_emit_json(
|
|
184
|
+
{
|
|
185
|
+
"created": sorted(created),
|
|
186
|
+
"existing": sorted(existing),
|
|
187
|
+
"skipped": sorted(skipped),
|
|
188
|
+
"config": str(config_file),
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
raise typer.Exit(code=EXIT_OK)
|
|
192
|
+
|
|
193
|
+
typer.echo(f"config: {config_file}")
|
|
194
|
+
for path in sorted(created):
|
|
195
|
+
typer.echo(f"created: {path}")
|
|
196
|
+
for path in sorted(existing):
|
|
197
|
+
typer.echo(f"existing: {path}")
|
|
198
|
+
raise typer.Exit(code=EXIT_OK)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# new
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.command("new")
|
|
207
|
+
def new_cmd(
|
|
208
|
+
title: str = typer.Argument(..., help="Requirement title."),
|
|
209
|
+
kind: str = typer.Option("functional", "--kind", help="Requirement kind."),
|
|
210
|
+
priority: str = typer.Option("must", "--priority", help="Requirement priority."),
|
|
211
|
+
tag: list[str] = typer.Option([], "--tag", help="Tag (repeatable).", metavar="TAG"),
|
|
212
|
+
criterion: list[str] = typer.Option(
|
|
213
|
+
[],
|
|
214
|
+
"--criterion",
|
|
215
|
+
help="Acceptance criterion statement (repeatable).",
|
|
216
|
+
metavar="STATEMENT",
|
|
217
|
+
),
|
|
218
|
+
status: str = typer.Option("draft", "--status", help="Initial requirement status."),
|
|
219
|
+
source: str = typer.Option("manual", "--source", help="Requirement source."),
|
|
220
|
+
json_output: bool = typer.Option(
|
|
221
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
222
|
+
),
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Create a new requirement record."""
|
|
225
|
+
config = _resolve_config()
|
|
226
|
+
try:
|
|
227
|
+
records = _load_workspace_records(config)
|
|
228
|
+
except ReqLedgerError as exc:
|
|
229
|
+
typer.echo(f"error: {exc}", err=True)
|
|
230
|
+
_config_error(EXIT_USAGE)
|
|
231
|
+
|
|
232
|
+
existing_ids = store_mod.existing_requirement_ids(records)
|
|
233
|
+
new_id = id_utils.next_requirement_id(existing_ids, config)
|
|
234
|
+
|
|
235
|
+
# Refuse file/id collisions defensively.
|
|
236
|
+
target_path = config.records_dir / id_utils.requirement_filename(new_id, config)
|
|
237
|
+
if target_path.exists():
|
|
238
|
+
typer.echo(f"error: record file already exists: {target_path}", err=True)
|
|
239
|
+
_config_error(EXIT_USAGE)
|
|
240
|
+
|
|
241
|
+
criteria: list[dict[str, object]] = []
|
|
242
|
+
for index, statement in enumerate(criterion, start=1):
|
|
243
|
+
cid = f"{config.criterion_prefix}-{index:0{config.width}d}"
|
|
244
|
+
criteria.append(
|
|
245
|
+
{
|
|
246
|
+
"id": cid,
|
|
247
|
+
"statement": statement,
|
|
248
|
+
"verification": "behavior",
|
|
249
|
+
"status": "accepted" if status == "accepted" else "draft",
|
|
250
|
+
"tags": list(tag),
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
today = _utc_today()
|
|
255
|
+
metadata: dict[str, object] = {
|
|
256
|
+
"schema_version": config.schema_version,
|
|
257
|
+
"id": new_id,
|
|
258
|
+
"title": title,
|
|
259
|
+
"kind": kind,
|
|
260
|
+
"status": status,
|
|
261
|
+
"priority": priority,
|
|
262
|
+
"owner": "",
|
|
263
|
+
"tags": list(tag),
|
|
264
|
+
"parent_ids": [],
|
|
265
|
+
"supersedes": [],
|
|
266
|
+
"superseded_by": [],
|
|
267
|
+
"task_refs": [],
|
|
268
|
+
"arch_refs": [],
|
|
269
|
+
"spec_refs": [],
|
|
270
|
+
"evidence_refs": [],
|
|
271
|
+
"source": source,
|
|
272
|
+
"source_refs": [],
|
|
273
|
+
"created": today,
|
|
274
|
+
"updated": today,
|
|
275
|
+
"criteria": criteria,
|
|
276
|
+
}
|
|
277
|
+
body = f"# {new_id}: {title}\n\n## Intent\n\nTODO: describe intent.\n"
|
|
278
|
+
content = render_record_text(metadata, body)
|
|
279
|
+
|
|
280
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
281
|
+
target_path.write_text(content, encoding="utf-8")
|
|
282
|
+
|
|
283
|
+
if json_output:
|
|
284
|
+
_emit_json({"id": new_id, "path": target_path.as_posix()})
|
|
285
|
+
raise typer.Exit(code=EXIT_OK)
|
|
286
|
+
|
|
287
|
+
typer.echo(f"created: {new_id} -> {target_path}")
|
|
288
|
+
raise typer.Exit(code=EXIT_OK)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# list
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.command("list")
|
|
297
|
+
def list_cmd(
|
|
298
|
+
status: str | None = typer.Option(None, "--status", help="Filter by status."),
|
|
299
|
+
tag: str | None = typer.Option(None, "--tag", help="Filter by tag."),
|
|
300
|
+
json_output: bool = typer.Option(
|
|
301
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
302
|
+
),
|
|
303
|
+
) -> None:
|
|
304
|
+
"""List requirement records."""
|
|
305
|
+
config = _resolve_config()
|
|
306
|
+
try:
|
|
307
|
+
records = _load_workspace_records(config)
|
|
308
|
+
except ReqLedgerError as exc:
|
|
309
|
+
typer.echo(f"error: {exc}", err=True)
|
|
310
|
+
_config_error(EXIT_USAGE)
|
|
311
|
+
|
|
312
|
+
filtered = [
|
|
313
|
+
r
|
|
314
|
+
for r in records
|
|
315
|
+
if (status is None or r.status == status) and (tag is None or tag in r.tags)
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
if json_output:
|
|
319
|
+
_emit_json([r.to_manifest_entry() for r in filtered])
|
|
320
|
+
raise typer.Exit(code=EXIT_OK)
|
|
321
|
+
|
|
322
|
+
for record in filtered:
|
|
323
|
+
parts = (record.id, record.status, record.priority, record.kind, record.title)
|
|
324
|
+
typer.echo(" ".join(parts))
|
|
325
|
+
raise typer.Exit(code=EXIT_OK)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# show
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@app.command("show")
|
|
334
|
+
def show_cmd(
|
|
335
|
+
requirement_id: str = typer.Argument(..., help="Requirement ID (e.g. REQ-0001)."),
|
|
336
|
+
json_output: bool = typer.Option(
|
|
337
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
338
|
+
),
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Show a single requirement record."""
|
|
341
|
+
config = _resolve_config()
|
|
342
|
+
try:
|
|
343
|
+
records = _load_workspace_records(config)
|
|
344
|
+
record = store_mod.resolve_single(records, requirement_id)
|
|
345
|
+
except (NotFoundError, DuplicateIdError) as exc:
|
|
346
|
+
typer.echo(f"error: {exc}", err=True)
|
|
347
|
+
_config_error(EXIT_VALIDATION)
|
|
348
|
+
except ReqLedgerError as exc:
|
|
349
|
+
typer.echo(f"error: {exc}", err=True)
|
|
350
|
+
_config_error(EXIT_USAGE)
|
|
351
|
+
|
|
352
|
+
if json_output:
|
|
353
|
+
_emit_json(record.to_manifest_entry())
|
|
354
|
+
raise typer.Exit(code=EXIT_OK)
|
|
355
|
+
|
|
356
|
+
typer.echo(f"id: {record.id}")
|
|
357
|
+
typer.echo(f"title: {record.title}")
|
|
358
|
+
typer.echo(f"kind: {record.kind}")
|
|
359
|
+
typer.echo(f"status: {record.status}")
|
|
360
|
+
typer.echo(f"priority: {record.priority}")
|
|
361
|
+
typer.echo(f"tags: {', '.join(record.tags)}")
|
|
362
|
+
typer.echo(f"source: {record.source}")
|
|
363
|
+
typer.echo(f"path: {record.path.as_posix()}")
|
|
364
|
+
typer.echo("criteria:")
|
|
365
|
+
for crit in record.criteria:
|
|
366
|
+
typer.echo(
|
|
367
|
+
f" - {crit.id} [{crit.status}/{crit.verification}]: {crit.statement}"
|
|
368
|
+
)
|
|
369
|
+
raise typer.Exit(code=EXIT_OK)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# validate
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@app.command("validate")
|
|
378
|
+
def validate_cmd(
|
|
379
|
+
json_output: bool = typer.Option(
|
|
380
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
381
|
+
),
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Validate all requirement records (fail-closed)."""
|
|
384
|
+
config = _resolve_config()
|
|
385
|
+
try:
|
|
386
|
+
paths = store_mod.discover_records(config.records_dir)
|
|
387
|
+
records = store_mod.load_records(paths)
|
|
388
|
+
except ReqLedgerError as exc:
|
|
389
|
+
typer.echo(f"error: {exc}", err=True)
|
|
390
|
+
_config_error(EXIT_USAGE)
|
|
391
|
+
|
|
392
|
+
raw_dicts: dict[str, dict[str, object]] = {}
|
|
393
|
+
parse_failures: list[dict[str, object]] = []
|
|
394
|
+
for path in paths:
|
|
395
|
+
try:
|
|
396
|
+
metadata, _body = split_front_matter_text(path.read_text(encoding="utf-8"))
|
|
397
|
+
except ParseError as exc:
|
|
398
|
+
parse_failures.append(
|
|
399
|
+
{
|
|
400
|
+
"severity": "error",
|
|
401
|
+
"code": review_mod.RQL014,
|
|
402
|
+
"message": str(exc),
|
|
403
|
+
"requirement_id": "",
|
|
404
|
+
"criterion_id": "",
|
|
405
|
+
"path": path.as_posix(),
|
|
406
|
+
}
|
|
407
|
+
)
|
|
408
|
+
continue
|
|
409
|
+
rid = str(metadata.get("id", ""))
|
|
410
|
+
raw_dicts[rid] = metadata
|
|
411
|
+
|
|
412
|
+
findings = review_mod.validate_records(records, raw_dicts=raw_dicts, config=config)
|
|
413
|
+
errors = [f for f in findings if f.severity == "error"]
|
|
414
|
+
error_payloads = parse_failures + [f.to_dict() for f in errors]
|
|
415
|
+
|
|
416
|
+
if json_output:
|
|
417
|
+
_emit_json(
|
|
418
|
+
{
|
|
419
|
+
"ok": not error_payloads,
|
|
420
|
+
"errors": error_payloads,
|
|
421
|
+
"warnings": [f.to_dict() for f in findings if f.severity == "warning"],
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
raise typer.Exit(code=EXIT_OK if not error_payloads else EXIT_VALIDATION)
|
|
425
|
+
|
|
426
|
+
if parse_failures:
|
|
427
|
+
for item in parse_failures:
|
|
428
|
+
typer.echo(
|
|
429
|
+
f"error: {item['code']} {item['path']}: {item['message']}", err=True
|
|
430
|
+
)
|
|
431
|
+
for finding in findings:
|
|
432
|
+
stream = sys.stderr if finding.severity == "error" else sys.stdout
|
|
433
|
+
text = f"{finding.severity}: {finding.code} {finding.requirement_id}"
|
|
434
|
+
typer.echo(f"{text}: {finding.message}", file=stream)
|
|
435
|
+
if error_payloads:
|
|
436
|
+
typer.echo(f"validation failed with {len(error_payloads)} error(s)", err=True)
|
|
437
|
+
raise typer.Exit(code=EXIT_VALIDATION)
|
|
438
|
+
typer.echo(f"validation ok: {len(records)} record(s)")
|
|
439
|
+
raise typer.Exit(code=EXIT_OK)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
# index
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@app.command("index")
|
|
448
|
+
def index_cmd(
|
|
449
|
+
json_output: bool = typer.Option(
|
|
450
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
451
|
+
),
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Validate, then write the deterministic manifest.json."""
|
|
454
|
+
config = _resolve_config()
|
|
455
|
+
try:
|
|
456
|
+
paths = store_mod.discover_records(config.records_dir)
|
|
457
|
+
records = store_mod.load_records(paths)
|
|
458
|
+
except ReqLedgerError as exc:
|
|
459
|
+
typer.echo(f"error: {exc}", err=True)
|
|
460
|
+
_config_error(EXIT_USAGE)
|
|
461
|
+
|
|
462
|
+
raw_dicts: dict[str, dict[str, object]] = {}
|
|
463
|
+
parse_failures = False
|
|
464
|
+
for path in paths:
|
|
465
|
+
try:
|
|
466
|
+
metadata, _body = split_front_matter_text(path.read_text(encoding="utf-8"))
|
|
467
|
+
except ParseError as exc:
|
|
468
|
+
typer.echo(f"error: {exc}", err=True)
|
|
469
|
+
parse_failures = True
|
|
470
|
+
continue
|
|
471
|
+
raw_dicts[str(metadata.get("id", ""))] = metadata
|
|
472
|
+
|
|
473
|
+
findings = review_mod.validate_records(records, raw_dicts=raw_dicts, config=config)
|
|
474
|
+
errors = [f for f in findings if f.severity == "error"]
|
|
475
|
+
if parse_failures or errors:
|
|
476
|
+
if json_output:
|
|
477
|
+
_emit_json(
|
|
478
|
+
{
|
|
479
|
+
"ok": False,
|
|
480
|
+
"errors": [f.to_dict() for f in errors],
|
|
481
|
+
"manifest_path": None,
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
for finding in errors:
|
|
486
|
+
msg = (
|
|
487
|
+
f"error: {finding.code} {finding.requirement_id}: {finding.message}"
|
|
488
|
+
)
|
|
489
|
+
typer.echo(msg, err=True)
|
|
490
|
+
typer.echo("validation failed; manifest not written", err=True)
|
|
491
|
+
raise typer.Exit(code=EXIT_VALIDATION)
|
|
492
|
+
|
|
493
|
+
manifest_mod.write_manifest(
|
|
494
|
+
records,
|
|
495
|
+
config.manifest,
|
|
496
|
+
schema_version=config.schema_version,
|
|
497
|
+
base_path=config.workspace_root,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
if json_output:
|
|
501
|
+
_emit_json(
|
|
502
|
+
{
|
|
503
|
+
"ok": True,
|
|
504
|
+
"manifest_path": config.manifest.as_posix(),
|
|
505
|
+
"requirements": len(records),
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
raise typer.Exit(code=EXIT_OK)
|
|
509
|
+
|
|
510
|
+
typer.echo(f"manifest written: {config.manifest} ({len(records)} requirements)")
|
|
511
|
+
raise typer.Exit(code=EXIT_OK)
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
# link
|
|
516
|
+
# ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.command("link")
|
|
520
|
+
def link_cmd(
|
|
521
|
+
requirement_id: str = typer.Argument(..., help="Requirement ID to link."),
|
|
522
|
+
task: str | None = typer.Option(None, "--task", help="Task reference."),
|
|
523
|
+
arch: str | None = typer.Option(None, "--arch", help="Architecture reference."),
|
|
524
|
+
spec: str | None = typer.Option(
|
|
525
|
+
None, "--spec", help="Spec reference (path or id)."
|
|
526
|
+
),
|
|
527
|
+
evidence: str | None = typer.Option(None, "--evidence", help="Evidence reference."),
|
|
528
|
+
json_output: bool = typer.Option(
|
|
529
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
530
|
+
),
|
|
531
|
+
) -> None:
|
|
532
|
+
"""Link a requirement to external references."""
|
|
533
|
+
config = _resolve_config()
|
|
534
|
+
try:
|
|
535
|
+
records = _load_workspace_records(config)
|
|
536
|
+
record = store_mod.resolve_single(records, requirement_id)
|
|
537
|
+
except (NotFoundError, DuplicateIdError) as exc:
|
|
538
|
+
typer.echo(f"error: {exc}", err=True)
|
|
539
|
+
_config_error(EXIT_VALIDATION)
|
|
540
|
+
except ReqLedgerError as exc:
|
|
541
|
+
typer.echo(f"error: {exc}", err=True)
|
|
542
|
+
_config_error(EXIT_USAGE)
|
|
543
|
+
|
|
544
|
+
additions: list[tuple[str, str, list[str]]] = []
|
|
545
|
+
if task:
|
|
546
|
+
additions.append(("task", task, list(record.task_refs)))
|
|
547
|
+
if arch:
|
|
548
|
+
additions.append(("arch", arch, list(record.arch_refs)))
|
|
549
|
+
if spec:
|
|
550
|
+
additions.append(("spec", spec, list(record.spec_refs)))
|
|
551
|
+
if evidence:
|
|
552
|
+
additions.append(("evidence", evidence, list(record.evidence_refs)))
|
|
553
|
+
|
|
554
|
+
if not additions:
|
|
555
|
+
typer.echo(
|
|
556
|
+
"error: provide at least one of --task/--arch/--spec/--evidence", err=True
|
|
557
|
+
)
|
|
558
|
+
_config_error(EXIT_USAGE)
|
|
559
|
+
|
|
560
|
+
warnings: list[str] = []
|
|
561
|
+
field_map = {
|
|
562
|
+
"task": "task_refs",
|
|
563
|
+
"arch": "arch_refs",
|
|
564
|
+
"spec": "spec_refs",
|
|
565
|
+
"evidence": "evidence_refs",
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
raw = record.path.read_text(encoding="utf-8")
|
|
569
|
+
metadata, body = split_front_matter_text(raw)
|
|
570
|
+
for kind, value, _existing in additions:
|
|
571
|
+
# Warn (not fail) on missing local path refs.
|
|
572
|
+
candidate = Path(value)
|
|
573
|
+
if "/" in value and not candidate.exists() and not _looks_like_id(value):
|
|
574
|
+
warnings.append(f"warning: local path ref does not exist: {value}")
|
|
575
|
+
field_name = field_map[kind]
|
|
576
|
+
current = list(metadata.get(field_name, [])) # type: ignore[arg-type]
|
|
577
|
+
if value not in current:
|
|
578
|
+
current.append(value)
|
|
579
|
+
metadata[field_name] = current
|
|
580
|
+
metadata["updated"] = _utc_today()
|
|
581
|
+
new_text = render_record_text(metadata, body)
|
|
582
|
+
record.path.write_text(new_text, encoding="utf-8")
|
|
583
|
+
|
|
584
|
+
if json_output:
|
|
585
|
+
_emit_json(
|
|
586
|
+
{
|
|
587
|
+
"id": record.id,
|
|
588
|
+
"path": record.path.as_posix(),
|
|
589
|
+
"warnings": warnings,
|
|
590
|
+
"task_refs": metadata.get("task_refs", []),
|
|
591
|
+
"arch_refs": metadata.get("arch_refs", []),
|
|
592
|
+
"spec_refs": metadata.get("spec_refs", []),
|
|
593
|
+
"evidence_refs": metadata.get("evidence_refs", []),
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
raise typer.Exit(code=EXIT_OK)
|
|
597
|
+
|
|
598
|
+
for message in warnings:
|
|
599
|
+
typer.echo(message)
|
|
600
|
+
typer.echo(f"linked: {record.id} -> {record.path}")
|
|
601
|
+
raise typer.Exit(code=EXIT_OK)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _looks_like_id(value: str) -> bool:
|
|
605
|
+
return "-" in value and value.split("-")[0].isalpha()
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# ---------------------------------------------------------------------------
|
|
609
|
+
# review
|
|
610
|
+
# ---------------------------------------------------------------------------
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@app.command("review")
|
|
614
|
+
def review_cmd(
|
|
615
|
+
json_output: bool = typer.Option(
|
|
616
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
617
|
+
),
|
|
618
|
+
) -> None:
|
|
619
|
+
"""Write review.md and review.json reports (fail-closed)."""
|
|
620
|
+
config = _resolve_config()
|
|
621
|
+
try:
|
|
622
|
+
paths = store_mod.discover_records(config.records_dir)
|
|
623
|
+
records = store_mod.load_records(paths)
|
|
624
|
+
except ReqLedgerError as exc:
|
|
625
|
+
typer.echo(f"error: {exc}", err=True)
|
|
626
|
+
_config_error(EXIT_USAGE)
|
|
627
|
+
|
|
628
|
+
review_mod.write_review_reports(
|
|
629
|
+
records,
|
|
630
|
+
config=config,
|
|
631
|
+
markdown_path=config.reports_state_dir / "review.md",
|
|
632
|
+
json_path=config.reports_state_dir / "review.json",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if json_output:
|
|
636
|
+
report = review_mod.build_review_report(records, config=config)
|
|
637
|
+
_emit_json(report)
|
|
638
|
+
raise typer.Exit(code=EXIT_OK)
|
|
639
|
+
|
|
640
|
+
typer.echo(
|
|
641
|
+
f"review written: {config.reports_state_dir / 'review.md'} "
|
|
642
|
+
f"({config.reports_state_dir / 'review.json'})"
|
|
643
|
+
)
|
|
644
|
+
raise typer.Exit(code=EXIT_OK)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
# ---------------------------------------------------------------------------
|
|
648
|
+
# export
|
|
649
|
+
# ---------------------------------------------------------------------------
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@app.command("export")
|
|
653
|
+
def export_cmd(
|
|
654
|
+
fmt: str = typer.Option("json", "--format", help="Export format (MVP: json only)."),
|
|
655
|
+
output: str | None = typer.Option(
|
|
656
|
+
None, "--output", help="Output file (default: stdout).", metavar="PATH"
|
|
657
|
+
),
|
|
658
|
+
json_output: bool = typer.Option(
|
|
659
|
+
False, "--json", help="Emit machine-readable JSON."
|
|
660
|
+
),
|
|
661
|
+
) -> None:
|
|
662
|
+
"""Export machine-readable JSON for downstream tools."""
|
|
663
|
+
if fmt != "json":
|
|
664
|
+
typer.echo(
|
|
665
|
+
f"error: unsupported format {fmt!r} (MVP supports only 'json')", err=True
|
|
666
|
+
)
|
|
667
|
+
_config_error(EXIT_USAGE)
|
|
668
|
+
|
|
669
|
+
config = _resolve_config()
|
|
670
|
+
try:
|
|
671
|
+
paths = store_mod.discover_records(config.records_dir)
|
|
672
|
+
records = store_mod.load_records(paths)
|
|
673
|
+
except ReqLedgerError as exc:
|
|
674
|
+
typer.echo(f"error: {exc}", err=True)
|
|
675
|
+
_config_error(EXIT_USAGE)
|
|
676
|
+
|
|
677
|
+
text = manifest_mod.render_export_json(
|
|
678
|
+
records, schema_version=config.schema_version, base_path=config.workspace_root
|
|
679
|
+
)
|
|
680
|
+
if output:
|
|
681
|
+
out_path = Path(output)
|
|
682
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
683
|
+
out_path.write_text(text, encoding="utf-8")
|
|
684
|
+
if json_output:
|
|
685
|
+
_emit_json({"exported": True, "path": out_path.as_posix()})
|
|
686
|
+
else:
|
|
687
|
+
typer.echo(f"export written: {out_path}")
|
|
688
|
+
raise typer.Exit(code=EXIT_OK)
|
|
689
|
+
|
|
690
|
+
typer.echo(text)
|
|
691
|
+
raise typer.Exit(code=EXIT_OK)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
__all__ = ["app"]
|