specmason 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.
specmason/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """SpecMason: Ledgerwerk behavior/specification builder, checker, and reconciler.
2
+
3
+ SpecMason is the builder, checker, and reconciliation tool for behavior
4
+ artifacts. It connects accepted requirements (owned by ReqLedger) to concrete
5
+ behavior examples (Gherkin), pytest tests, mapping inventories, coverage
6
+ reports, and execution evidence.
7
+
8
+ SpecMason depends on :mod:`ledgercore` for generic storage/ledger primitives
9
+ (atomic IO, deterministic JSON, config and path resolution, prefixed IDs, ref
10
+ parsing, hashing, and timestamps). It never imports ReqLedger at runtime; it
11
+ only reads ReqLedger export JSON.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ try:
17
+ from specmason._version import __version__
18
+ except Exception: # pragma: no cover - fallback when _version.py absent
19
+ __version__ = "0.0.0+unknown"
20
+
21
+ __all__ = ["__version__"]
specmason/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m specmason`` to invoke the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from specmason.launcher import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
specmason/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'gee7262de2'
specmason/cli.py ADDED
@@ -0,0 +1,468 @@
1
+ """SpecMason Typer CLI.
2
+
3
+ All commands support ``--json`` for machine-readable output. Global options
4
+ (``--version``, ``--config``) are resolved at the callback level.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import replace
10
+ from pathlib import Path
11
+
12
+ import typer
13
+
14
+ from specmason import __version__
15
+ from specmason.config import load_config
16
+ from specmason.coverage import build_coverage, render_markdown
17
+ from specmason.create import generate_features
18
+ from specmason.errors import Findings
19
+ from specmason.evidence import check_evidence_against_mappings, parse_junit_xml
20
+ from specmason.gherkin.model import Feature
21
+ from specmason.init import init_workspace
22
+ from specmason.mappings import (
23
+ MappingInventory,
24
+ build_inventory,
25
+ load_intentional_unmapped_policy,
26
+ )
27
+ from specmason.pytest_discovery import discover_tests
28
+ from specmason.requirements import RequirementsIndex, load_manifest
29
+ from specmason.review import run_review
30
+
31
+ app = typer.Typer(
32
+ name="specmason",
33
+ help=(
34
+ "SpecMason: build, check, and reconcile behavior/specification artifacts, "
35
+ "pytest mappings, reverse coverage, and execution evidence."
36
+ ),
37
+ no_args_is_help=True,
38
+ add_completion=False,
39
+ )
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Callback
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ def _version_callback(value: bool) -> None:
48
+ if value:
49
+ typer.echo(__version__)
50
+ raise typer.Exit
51
+
52
+
53
+ @app.callback()
54
+ def main_callback(
55
+ version: bool = typer.Option(
56
+ None,
57
+ "--version",
58
+ callback=_version_callback,
59
+ is_eager=True,
60
+ help="Show the SpecMason version and exit.",
61
+ ),
62
+ ) -> None:
63
+ """SpecMason command group."""
64
+ return None
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def _to_json(payload: dict[str, object]) -> str:
73
+ from ledgercore.jsonio import dumps_json
74
+
75
+ return dumps_json(payload, indent=2, sort_keys=True)
76
+
77
+
78
+ def _resolve_config(
79
+ *,
80
+ config: Path | None,
81
+ requirements: str | None,
82
+ ) -> object:
83
+ cfg = load_config(config=config, requirements_override=requirements)
84
+ return cfg
85
+
86
+
87
+ def _load_index(cfg: object) -> RequirementsIndex | None:
88
+ from specmason.config import SpecMasonConfig
89
+
90
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
91
+ if c.is_integrated:
92
+ return load_manifest(c.requirements_manifest)
93
+ return None
94
+
95
+
96
+ def _load_inventory(cfg: object) -> MappingInventory:
97
+ from specmason.config import SpecMasonConfig
98
+
99
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
100
+ waivers, _ = load_intentional_unmapped_policy(c.pytest_intentional_unmapped_policy)
101
+ discovered = discover_tests(c.tests_dir, root=c.workspace_root)
102
+ return build_inventory(discovered, central_waivers=waivers)
103
+
104
+
105
+ def _load_features(cfg: object) -> tuple[list[Feature], Findings]:
106
+ from specmason.config import SpecMasonConfig
107
+ from specmason.corpus import parse_corpus
108
+
109
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
110
+ if not c.features_dir.is_dir():
111
+ return [], Findings()
112
+ features, _, raw_findings = parse_corpus(
113
+ c.features_dir, official_parser=c.gherkin_official_parser
114
+ )
115
+ return features, Findings.of(*raw_findings)
116
+
117
+
118
+ def _exit(result: dict[str, object], *, json_output: bool, errors: bool) -> None:
119
+ if json_output:
120
+ typer.echo(_to_json(result))
121
+ if errors:
122
+ raise typer.Exit(code=1)
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Commands
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ @app.command("init")
131
+ def init_command(
132
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
133
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
134
+ config: Path | None = typer.Option(
135
+ None, "--config", help="Workspace root config path."
136
+ ),
137
+ ) -> None:
138
+ """Initialize the SpecMason workspace layout."""
139
+ root = config.parent.resolve() if config else Path.cwd()
140
+ result = init_workspace(root, force=force)
141
+ if json_output:
142
+ typer.echo(_to_json(result.to_dict()))
143
+ return
144
+ typer.echo(f"Initialized SpecMason workspace at {result.root}")
145
+ if result.created:
146
+ typer.echo(f"created: {', '.join(result.created)}")
147
+ if result.existing:
148
+ typer.echo(f"existing: {', '.join(result.existing)}")
149
+ if result.skipped:
150
+ typer.echo(f"skipped: {', '.join(result.skipped)}")
151
+ if result.overwritten:
152
+ typer.echo(f"overwritten: {', '.join(result.overwritten)}")
153
+
154
+
155
+ @app.command("check")
156
+ def check_command(
157
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
158
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
159
+ requirements: str | None = typer.Option(
160
+ None, "--requirements", help="ReqLedger manifest path."
161
+ ),
162
+ ) -> None:
163
+ """Validate config, features, mappings, and waivers."""
164
+ from specmason.gherkin.lint import lint_feature_with_authority
165
+
166
+ cfg = _resolve_config(config=config, requirements=requirements)
167
+
168
+ from specmason.config import SpecMasonConfig
169
+
170
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
171
+ index = _load_index(c)
172
+ features, findings = _load_features(c)
173
+
174
+ for feature in features:
175
+ findings = findings.extend(
176
+ Findings.of(
177
+ *lint_feature_with_authority(
178
+ feature,
179
+ known_requirement_ids=index.requirement_ids if index else None,
180
+ known_criterion_ids=index.criterion_ids if index else None,
181
+ require_req_tag=c.gherkin_require_req_tag,
182
+ require_ac_tag=c.gherkin_require_ac_tag,
183
+ )
184
+ )
185
+ )
186
+
187
+ waivers, policy_findings = load_intentional_unmapped_policy(
188
+ c.pytest_intentional_unmapped_policy
189
+ )
190
+ combined = findings.extend(Findings.of(*policy_findings))
191
+ errors = combined.has_errors
192
+
193
+ result = {"findings": combined.to_list(), "has_errors": errors}
194
+ if json_output:
195
+ typer.echo(_to_json(result))
196
+ else:
197
+ for line in combined.render_lines():
198
+ typer.echo(line)
199
+ typer.echo(f"{len(combined)} findings ({len(combined.errors)} errors)")
200
+ if errors:
201
+ raise typer.Exit(code=1)
202
+
203
+
204
+ @app.command("create-gherkin")
205
+ def create_gherkin_command(
206
+ from_manifest: str = typer.Option(
207
+ ..., "--from", help="ReqLedger manifest JSON path."
208
+ ),
209
+ area: str | None = typer.Option(
210
+ None, "--area", help="Filter requirements by area tag/kind."
211
+ ),
212
+ dry_run: bool = typer.Option(False, "--dry-run", help="Don't write files."),
213
+ force: bool = typer.Option(False, "--force", help="Overwrite existing files."),
214
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
215
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
216
+ ) -> None:
217
+ """Generate draft Gherkin feature files from accepted behavior criteria."""
218
+ cfg = _resolve_config(config=config, requirements=from_manifest)
219
+ from specmason.config import SpecMasonConfig
220
+
221
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
222
+ index = load_manifest(from_manifest)
223
+ result = generate_features(
224
+ index,
225
+ c.features_dir,
226
+ area=area,
227
+ force=force,
228
+ dry_run=dry_run,
229
+ )
230
+ if json_output:
231
+ typer.echo(_to_json(result.to_dict()))
232
+ return
233
+ for f in result.features:
234
+ typer.echo(f"{f.status}: {f.path}")
235
+ typer.echo(f"{len(result.features)} features")
236
+
237
+
238
+ @app.command("discover-pytest")
239
+ def discover_pytest_command(
240
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
241
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
242
+ ) -> None:
243
+ """Discover pytest tests without importing test modules."""
244
+ cfg = _resolve_config(config=config, requirements=None)
245
+ from specmason.config import SpecMasonConfig
246
+
247
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
248
+ discovered = discover_tests(c.tests_dir, root=c.workspace_root)
249
+ result = {"tests": [t.nodeid for t in discovered], "count": len(discovered)}
250
+ if json_output:
251
+ typer.echo(_to_json(result))
252
+ else:
253
+ for t in discovered:
254
+ typer.echo(t.nodeid)
255
+ typer.echo(f"{len(discovered)} tests")
256
+
257
+
258
+ @app.command("coverage")
259
+ def coverage_command(
260
+ view: str = typer.Option("both", "--view", help="requirements|tests|both"),
261
+ show: str = typer.Option("all", "--show", help="gaps|all"),
262
+ requirements: str | None = typer.Option(
263
+ None, "--requirements", help="ReqLedger manifest path."
264
+ ),
265
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
266
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
267
+ ) -> None:
268
+ """Report requirement-to-test and test-to-requirement coverage."""
269
+ cfg = _resolve_config(config=config, requirements=requirements)
270
+ from specmason.config import SpecMasonConfig
271
+
272
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
273
+ index = _load_index(c)
274
+ features, load_findings = _load_features(c)
275
+ inventory = _load_inventory(c)
276
+ report = build_coverage(features, inventory, index=index, mode=c.mode)
277
+ report = replace(report, findings=load_findings.extend(report.findings))
278
+ errors = report.has_errors
279
+
280
+ if json_output:
281
+ typer.echo(report.to_json())
282
+ else:
283
+ typer.echo(render_markdown(report))
284
+ if errors:
285
+ raise typer.Exit(code=1)
286
+
287
+
288
+ @app.command("mappings")
289
+ def mappings_command(
290
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
291
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
292
+ ) -> None:
293
+ """Show the pytest mapping inventory."""
294
+ cfg = _resolve_config(config=config, requirements=None)
295
+ from specmason.config import SpecMasonConfig
296
+
297
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
298
+ inventory = _load_inventory(c)
299
+ if json_output:
300
+ typer.echo(_to_json(inventory.to_dict()))
301
+ else:
302
+ for t in inventory.tests:
303
+ status = t.status
304
+ mappings = ", ".join(f"{m.req_id}/{m.ac_id}" for m in t.mappings) or "-"
305
+ typer.echo(f"{t.nodeid} {status} {mappings}")
306
+
307
+
308
+ @app.command("import-report")
309
+ def import_report_command(
310
+ format: str = typer.Argument("pytest-junit", help="Report format (pytest-junit)."),
311
+ path: str = typer.Argument(..., help="Path to JUnit XML report."),
312
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
313
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
314
+ ) -> None:
315
+ """Import pytest JUnit XML evidence."""
316
+ cfg = _resolve_config(config=config, requirements=None)
317
+ from specmason.config import SpecMasonConfig
318
+
319
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
320
+ report = parse_junit_xml(path)
321
+ inventory = _load_inventory(c)
322
+ mapped_nodeids = {t.nodeid for t in inventory.tests if t.is_mapped}
323
+ ev_findings = check_evidence_against_mappings(report, mapped_nodeids)
324
+ errors = ev_findings.has_errors
325
+ result = {
326
+ "entries": [e.to_dict() for e in report.entries],
327
+ "findings": ev_findings.to_list(),
328
+ }
329
+ if json_output:
330
+ typer.echo(_to_json(result))
331
+ else:
332
+ for e in report.entries:
333
+ typer.echo(f"{e.nodeid} {e.status} {e.time:.3f}s")
334
+ for f in ev_findings:
335
+ typer.echo(f.render())
336
+ if errors:
337
+ raise typer.Exit(code=1)
338
+
339
+
340
+ @app.command("review")
341
+ def review_command(
342
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
343
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
344
+ requirements: str | None = typer.Option(
345
+ None, "--requirements", help="ReqLedger manifest path."
346
+ ),
347
+ ) -> None:
348
+ """Run check + coverage + evidence and write reports."""
349
+ cfg = _resolve_config(config=config, requirements=requirements)
350
+ from specmason.config import SpecMasonConfig
351
+
352
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
353
+ index = _load_index(c)
354
+ result = run_review(c, index=index)
355
+ if json_output:
356
+ typer.echo(_to_json(result.to_dict()))
357
+ else:
358
+ for f in result.findings:
359
+ typer.echo(f.render())
360
+ typer.echo(f"reports: {', '.join(result.reports_written)}")
361
+ if result.has_errors:
362
+ raise typer.Exit(code=1)
363
+
364
+
365
+ # ---------------------------------------------------------------------------
366
+ # Corpus commands (external corpus mode)
367
+ # ---------------------------------------------------------------------------
368
+
369
+
370
+ corpus_app = typer.Typer(
371
+ name="corpus",
372
+ help="Inspect and inventory an external Gherkin corpus.",
373
+ no_args_is_help=True,
374
+ add_completion=False,
375
+ )
376
+ app.add_typer(corpus_app, name="corpus")
377
+
378
+
379
+ @corpus_app.command("inspect")
380
+ def corpus_inspect_command(
381
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
382
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
383
+ ) -> None:
384
+ """Inventory features, scenarios, outlines, steps, tags, fixtures, and findings."""
385
+ cfg = _resolve_config(config=config, requirements=None)
386
+ from specmason.config import SpecMasonConfig
387
+
388
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
389
+ from specmason.corpus import run_corpus_inspect
390
+
391
+ result, findings = run_corpus_inspect(
392
+ c.features_dir,
393
+ official_parser=c.gherkin_official_parser,
394
+ fixture_roots=c.external_corpus_fixture_roots,
395
+ namespace=c.external_corpus_id_namespace,
396
+ )
397
+ if json_output:
398
+ typer.echo(result.to_json())
399
+ else:
400
+ inv = result.inventory
401
+ typer.echo(f"Features: {inv.feature_count}")
402
+ typer.echo(f"Scenarios: {inv.scenario_count}")
403
+ typer.echo(f"Outlines: {inv.outline_count}")
404
+ typer.echo(f"Expanded examples: {inv.expanded_example_count}")
405
+ typer.echo(f"Step patterns: {inv.step_pattern_count}")
406
+ typer.echo(f"Steps: {inv.step_count}")
407
+ typer.echo(f"Tags: {inv.tag_count}")
408
+ typer.echo(f"Fixture refs: {inv.fixture_ref_count}")
409
+ for f in findings:
410
+ typer.echo(f.render())
411
+ if any(f.is_error() for f in findings):
412
+ raise typer.Exit(code=1)
413
+
414
+
415
+ @corpus_app.command("steps")
416
+ def corpus_steps_command(
417
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
418
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
419
+ ) -> None:
420
+ """Report the normalized step vocabulary."""
421
+ cfg = _resolve_config(config=config, requirements=None)
422
+ from specmason.config import SpecMasonConfig
423
+
424
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
425
+ from specmason.corpus import parse_corpus, render_steps_json, render_steps_markdown
426
+ from specmason.gherkin.step_vocab import build_step_vocabulary
427
+
428
+ features, _, findings = parse_corpus(
429
+ c.features_dir, official_parser=c.gherkin_official_parser
430
+ )
431
+ vocab = build_step_vocabulary(features)
432
+ if json_output:
433
+ typer.echo(render_steps_json(vocab))
434
+ else:
435
+ typer.echo(render_steps_markdown(vocab))
436
+ if any(f.is_error() for f in findings):
437
+ raise typer.Exit(code=1)
438
+
439
+
440
+ @corpus_app.command("fixtures")
441
+ def corpus_fixtures_command(
442
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON to stdout."),
443
+ config: Path | None = typer.Option(None, "--config", help="Config file path."),
444
+ ) -> None:
445
+ """Report extracted fixture references with resolution metadata."""
446
+ cfg = _resolve_config(config=config, requirements=None)
447
+ from specmason.config import SpecMasonConfig
448
+
449
+ c: SpecMasonConfig = cfg # type: ignore[assignment]
450
+ from specmason.corpus import parse_corpus, render_fixtures_json
451
+
452
+ features, _, findings = parse_corpus(
453
+ c.features_dir, official_parser=c.gherkin_official_parser
454
+ )
455
+ if json_output:
456
+ typer.echo(render_fixtures_json(features, c.external_corpus_fixture_roots))
457
+ else:
458
+ from specmason.fixtures import extract_fixture_refs
459
+
460
+ for feat in features:
461
+ for ref in extract_fixture_refs(feat, c.external_corpus_fixture_roots):
462
+ status = "exists" if ref.exists else "MISSING"
463
+ typer.echo(f"{feat.path}: {ref.raw} [{ref.kind}] {status}")
464
+ if any(f.is_error() for f in findings):
465
+ raise typer.Exit(code=1)
466
+
467
+
468
+ __all__ = ["app"]