requirements-as-code 0.4.2__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {requirements_as_code-0.4.2/requirements_as_code.egg-info → requirements_as_code-0.5.0}/PKG-INFO +62 -1
  2. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/README.md +61 -0
  3. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.5.0-artifact-improvement.md +5 -0
  4. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/artifacts.py +11 -0
  5. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/classification.py +24 -2
  6. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/cli.py +40 -6
  7. requirements_as_code-0.5.0/rac/improve.py +84 -0
  8. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/outputs.py +71 -1
  9. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0/requirements_as_code.egg-info}/PKG-INFO +62 -1
  10. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/requirements_as_code.egg-info/SOURCES.txt +2 -0
  11. requirements_as_code-0.5.0/tests/test_improve.py +213 -0
  12. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/.github/workflows/python-publish.yml +0 -0
  13. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/.gitignore +0 -0
  14. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/LICENSE +0 -0
  15. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/examples/example_dashboard_v1.md +0 -0
  16. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/examples/example_dashboard_v2.md +0 -0
  17. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-001-markdown-first.md +0 -0
  18. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-002-ai-optional.md +0 -0
  19. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-003-structured-outputs-first.md +0 -0
  20. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-004-artifact-model.md +0 -0
  21. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-005-cli-first.md +0 -0
  22. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-006-ingest-over-rewrite.md +0 -0
  23. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-007-json-contract-stability.md +0 -0
  24. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-008-agent-ready-architecture.md +0 -0
  25. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-009-ai-assisted-development.md +0 -0
  26. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-010-documents-are-not-artifacts.md +0 -0
  27. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-011-file-first-pipeline.md +0 -0
  28. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-012-open-core-strategy.md +0 -0
  29. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-013-leverage-existing-source-control-systems.md +0 -0
  30. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-014-viewer-agnostic-knowledge-artifacts.md +0 -0
  31. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-015-explorer-as-consumer.md +0 -0
  32. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/adr/adr-016-rac-managed-knowledge-not-work.md +0 -0
  33. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/future/v1.0-workspace-analysis.md +0 -0
  34. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/future/v1.1-review-engine.md +0 -0
  35. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/future/v1.2-mcp-server.md +0 -0
  36. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/future/v1.4-claude-skills.md +0 -0
  37. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/future/v1.4-python-sdk.md +0 -0
  38. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/archive/v0.5-decisions.md +0 -0
  39. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.2-stats.md +0 -0
  40. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.3-ingest.md +0 -0
  41. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.3.1-formats.md +0 -0
  42. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.4-inspect.md +0 -0
  43. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.4.1-expansion.md +0 -0
  44. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.4.1-inspect-expansion.md +0 -0
  45. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.4.2-decision-metadata.md +0 -0
  46. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.5.1-guided-improvement.md +0 -0
  47. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.6-roadmaps.md +0 -0
  48. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.7-prompts.md +0 -0
  49. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/planning/roadmap/v0.8-explorer.md +0 -0
  50. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/pyproject.toml +0 -0
  51. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/__init__.py +0 -0
  52. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/diff.py +0 -0
  53. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/fs.py +0 -0
  54. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/ingest.py +0 -0
  55. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/inspect.py +0 -0
  56. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/models.py +0 -0
  57. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/parser.py +0 -0
  58. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/stats.py +0 -0
  59. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/rac/validate.py +0 -0
  60. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/requirements_as_code.egg-info/dependency_links.txt +0 -0
  61. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/requirements_as_code.egg-info/entry_points.txt +0 -0
  62. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/requirements_as_code.egg-info/requires.txt +0 -0
  63. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/requirements_as_code.egg-info/top_level.txt +0 -0
  64. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/setup.cfg +0 -0
  65. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/conftest.py +0 -0
  66. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/bad_category.md +0 -0
  67. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/bad_status.md +0 -0
  68. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/minimal.md +0 -0
  69. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/portfolio/01_accepted_arch.md +0 -0
  70. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/portfolio/02_proposed_process.md +0 -0
  71. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/portfolio/03_no_metadata.md +0 -0
  72. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/decision/with_metadata.md +0 -0
  73. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/diff/new.md +0 -0
  74. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/diff/old.md +0 -0
  75. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/ingest/sample.md +0 -0
  76. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/inspect/ambiguous.md +0 -0
  77. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/inspect/decision.md +0 -0
  78. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/inspect/nested/another_requirement.md +0 -0
  79. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/inspect/requirement.md +0 -0
  80. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/duplicate_ids.md +0 -0
  81. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/empty_req_text.md +0 -0
  82. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/malformed_id.md +0 -0
  83. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/missing_id.md +0 -0
  84. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/missing_problem.md +0 -0
  85. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/missing_requirements.md +0 -0
  86. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/missing_title.md +0 -0
  87. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/invalid/multiple_titles.md +0 -0
  88. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/portfolio/broken.md +0 -0
  89. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/portfolio/feature_a.md +0 -0
  90. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/portfolio/feature_b.md +0 -0
  91. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/portfolio/sub/feature_c.md +0 -0
  92. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/valid/bullet_requirements.md +0 -0
  93. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/valid/feature.md +0 -0
  94. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/valid/minimal.md +0 -0
  95. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/fixtures/valid/warnings.md +0 -0
  96. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_cli.py +0 -0
  97. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_decision_metadata.py +0 -0
  98. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_diff.py +0 -0
  99. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_ingest.py +0 -0
  100. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_inspect.py +0 -0
  101. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_parser.py +0 -0
  102. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_stats.py +0 -0
  103. {requirements_as_code-0.4.2 → requirements_as_code-0.5.0}/tests/test_validate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: requirements-as-code
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: RAC — lint and diff product requirements written in Markdown.
5
5
  Author: tcballard
6
6
  License-Expression: MIT
@@ -616,6 +616,67 @@ usage errors (file not found, or a non-Markdown file — convert it with
616
616
 
617
617
  ---
618
618
 
619
+ ## Improve
620
+
621
+ Where `inspect` tells you *what an artifact is*, `improve` tells you *what to add
622
+ next*. It reports the required and recommended sections an artifact is missing —
623
+ **deterministically, from the schema, with no AI** (ADR-002) — and can emit
624
+ Markdown templates for them. `improve` is **advisory and read-only**: it never
625
+ modifies your files and never generates content beyond `_TODO_` placeholders.
626
+
627
+ ```bash
628
+ rac improve requirement.md
629
+ rac improve requirement.md --template # Markdown skeletons for missing sections
630
+ rac improve requirement.md --json
631
+ cat requirement.md | rac improve - # stdin
632
+ rac ingest prd.docx --stdout | rac improve -
633
+ ```
634
+
635
+ Default output:
636
+
637
+ ```text
638
+ Artifact Type: Requirement
639
+
640
+ Missing Required:
641
+ (none)
642
+
643
+ Missing Recommended:
644
+ - Risks
645
+ - Assumptions
646
+ ```
647
+
648
+ `--template` turns the gaps into a ready-to-paste skeleton (required sections
649
+ first, then recommended), with a short guidance hint per section:
650
+
651
+ ```markdown
652
+ ## Risks
653
+
654
+ _TODO_
655
+
656
+ <!-- Potential implementation, delivery, or adoption risks -->
657
+ ```
658
+
659
+ So you can go straight from `rac inspect requirement.md` to
660
+ `rac improve requirement.md --template` without consulting any documentation.
661
+
662
+ `--json` returns a stable contract (ADR-007):
663
+
664
+ ```json
665
+ {
666
+ "type": "requirement",
667
+ "missing_required": [],
668
+ "missing_recommended": ["risks", "assumptions"]
669
+ }
670
+ ```
671
+
672
+ v0.5.0 generates suggestions for **Requirement** artifacts. Other known types
673
+ (e.g. Decision) and Unknown documents return a short explanatory message instead.
674
+ `improve` is advisory: it exits `0` for any completed analysis (with or without
675
+ suggestions) and `2` for usage errors. The presence of suggestions never changes
676
+ the exit code.
677
+
678
+ ---
679
+
619
680
  ## Review (Planned)
620
681
 
621
682
  AI-assisted product review.
@@ -576,6 +576,67 @@ usage errors (file not found, or a non-Markdown file — convert it with
576
576
 
577
577
  ---
578
578
 
579
+ ## Improve
580
+
581
+ Where `inspect` tells you *what an artifact is*, `improve` tells you *what to add
582
+ next*. It reports the required and recommended sections an artifact is missing —
583
+ **deterministically, from the schema, with no AI** (ADR-002) — and can emit
584
+ Markdown templates for them. `improve` is **advisory and read-only**: it never
585
+ modifies your files and never generates content beyond `_TODO_` placeholders.
586
+
587
+ ```bash
588
+ rac improve requirement.md
589
+ rac improve requirement.md --template # Markdown skeletons for missing sections
590
+ rac improve requirement.md --json
591
+ cat requirement.md | rac improve - # stdin
592
+ rac ingest prd.docx --stdout | rac improve -
593
+ ```
594
+
595
+ Default output:
596
+
597
+ ```text
598
+ Artifact Type: Requirement
599
+
600
+ Missing Required:
601
+ (none)
602
+
603
+ Missing Recommended:
604
+ - Risks
605
+ - Assumptions
606
+ ```
607
+
608
+ `--template` turns the gaps into a ready-to-paste skeleton (required sections
609
+ first, then recommended), with a short guidance hint per section:
610
+
611
+ ```markdown
612
+ ## Risks
613
+
614
+ _TODO_
615
+
616
+ <!-- Potential implementation, delivery, or adoption risks -->
617
+ ```
618
+
619
+ So you can go straight from `rac inspect requirement.md` to
620
+ `rac improve requirement.md --template` without consulting any documentation.
621
+
622
+ `--json` returns a stable contract (ADR-007):
623
+
624
+ ```json
625
+ {
626
+ "type": "requirement",
627
+ "missing_required": [],
628
+ "missing_recommended": ["risks", "assumptions"]
629
+ }
630
+ ```
631
+
632
+ v0.5.0 generates suggestions for **Requirement** artifacts. Other known types
633
+ (e.g. Decision) and Unknown documents return a short explanatory message instead.
634
+ `improve` is advisory: it exits `0` for any completed analysis (with or without
635
+ suggestions) and `2` for usage errors. The presence of suggestions never changes
636
+ the exit code.
637
+
638
+ ---
639
+
579
640
  ## Review (Planned)
580
641
 
581
642
  AI-assisted product review.
@@ -232,6 +232,11 @@ rac improve requirement.md --template
232
232
 
233
233
  without consulting documentation.
234
234
 
235
+ ### Completion
236
+
237
+ A user can take a valid but incomplete Requirement and generate a complete
238
+ RAC-compliant skeleton using only `rac improve --template`.
239
+
235
240
  ---
236
241
 
237
242
  ## Acceptance Criteria
@@ -35,6 +35,10 @@ class ArtifactSpec:
35
35
  # A value present in one of these sections that is not in its allowed set is
36
36
  # a validation error; a missing section is not (metadata stays optional).
37
37
  metadata: dict[str, tuple[str, ...]] = field(default_factory=dict)
38
+ # Short authoring hints per normalized section name, surfaced by `rac improve
39
+ # --template` as guidance comments. Optional; sections without a hint render
40
+ # without one.
41
+ descriptions: dict[str, str] = field(default_factory=dict)
38
42
  # Synonyms: alternate normalized headings that map onto a canonical section
39
43
  # name (e.g. "success criteria" -> "success metrics"). Applied before
40
44
  # matching, so synonyms contribute to confidence. Matching is deterministic
@@ -57,6 +61,13 @@ ARTIFACT_SPECS: tuple[ArtifactSpec, ...] = (
57
61
  display="Requirement",
58
62
  required=("problem", "requirements"),
59
63
  recommended=("success metrics", "risks", "assumptions"),
64
+ descriptions={
65
+ "problem": "The user or business problem this addresses",
66
+ "requirements": "Numbered requirement statements, e.g. [REQ-001] ...",
67
+ "success metrics": "How success will be measured",
68
+ "risks": "Potential implementation, delivery, or adoption risks",
69
+ "assumptions": "Assumptions this artifact depends on",
70
+ },
60
71
  synonyms={
61
72
  "success criteria": "success metrics",
62
73
  "kpis": "success metrics",
@@ -47,16 +47,38 @@ class Classification:
47
47
  missing_sections: list[str]
48
48
 
49
49
 
50
+ def _mapped(product: Product, spec) -> set[str]:
51
+ """The document's ``##`` headings, with this spec's synonyms applied.
52
+
53
+ The single source of synonym-aware section matching, shared by scoring
54
+ (:func:`score_artifacts`) and the scoring-independent :func:`missing_sections`.
55
+ """
56
+ return {spec.synonyms.get(h, h) for h in product.sections}
57
+
58
+
59
+ def missing_sections(product: Product, spec) -> tuple[list[str], list[str]]:
60
+ """Return ``(missing_required, missing_recommended)`` for ``spec``.
61
+
62
+ Synonym-aware and in schema declaration order. Independent of confidence
63
+ scoring (no :class:`TypeScore`) so callers like ``improve`` depend only on the
64
+ schema, not on classification internals.
65
+ """
66
+ mapped = _mapped(product, spec)
67
+ return (
68
+ [s for s in spec.required if s not in mapped],
69
+ [s for s in spec.recommended if s not in mapped],
70
+ )
71
+
72
+
50
73
  def score_artifacts(product: Product) -> list[TypeScore]:
51
74
  """Score the document against every artifact type, best fit first.
52
75
 
53
76
  Synonyms (e.g. "success criteria" -> "success metrics") are applied before
54
77
  matching, so they contribute to the score deterministically.
55
78
  """
56
- headings = list(product.sections)
57
79
  scores: list[TypeScore] = []
58
80
  for spec in ARTIFACT_SPECS:
59
- mapped = {spec.synonyms.get(h, h) for h in headings}
81
+ mapped = _mapped(product, spec)
60
82
  matched_required = [s for s in spec.required if s in mapped]
61
83
  matched_recommended = [s for s in spec.recommended if s in mapped]
62
84
  missing = [s for s in spec.expected if s not in mapped]
@@ -6,9 +6,10 @@ Commands:
6
6
  rac stats <directory> [--json]
7
7
  rac ingest <file> [-o OUT | --stdout] [--force] [--json]
8
8
  rac inspect <file.md | -> [--json]
9
+ rac improve <file.md | -> [--json | --template]
9
10
 
10
11
  Exit codes:
11
- 0 success (incl. inspect reporting Unknown)
12
+ 0 success (incl. inspect/improve reporting Unknown)
12
13
  1 validate: errors found; stats: no valid features or decisions;
13
14
  ingest: conversion failed
14
15
  2 usage / IO error (file not found, not a directory, unsupported type,
@@ -26,6 +27,7 @@ from . import outputs
26
27
  from .diff import diff as diff_asts
27
28
  from .ingest import ConversionError, UnsupportedDocument, ingest
28
29
  from .classification import score_artifacts
30
+ from .improve import improve_product
29
31
  from .inspect import build_inspection, inspect_directory
30
32
  from .parser import parse, parse_file
31
33
  from .stats import collect_stats
@@ -126,8 +128,8 @@ def cmd_ingest(args: argparse.Namespace) -> int:
126
128
  return EXIT_OK
127
129
 
128
130
 
129
- def _read_inspect_input(target: str) -> str:
130
- """Read inspect input from a Markdown file or stdin (``-``)."""
131
+ def _read_markdown_input(target: str, command: str) -> str:
132
+ """Read a Markdown file or stdin (``-``) for ``command`` (inspect/improve)."""
131
133
  if target == "-":
132
134
  return sys.stdin.read()
133
135
  path = Path(target)
@@ -136,7 +138,7 @@ def _read_inspect_input(target: str) -> str:
136
138
  raise SystemExit(EXIT_USAGE)
137
139
  if path.suffix.lower() not in (".md", ".markdown"):
138
140
  print(
139
- f"rac: inspect expects a Markdown file; "
141
+ f"rac: {command} expects a Markdown file; "
140
142
  f"convert it first with: rac ingest {target}",
141
143
  file=sys.stderr,
142
144
  )
@@ -160,7 +162,7 @@ def cmd_inspect(args: argparse.Namespace) -> int:
160
162
  return EXIT_OK
161
163
 
162
164
  # Single file (or stdin).
163
- text = _read_inspect_input(args.file)
165
+ text = _read_markdown_input(args.file, "inspect")
164
166
  product = parse(text)
165
167
  result = build_inspection(product)
166
168
  if args.verbose and not args.json:
@@ -173,6 +175,19 @@ def cmd_inspect(args: argparse.Namespace) -> int:
173
175
  return EXIT_OK
174
176
 
175
177
 
178
+ def cmd_improve(args: argparse.Namespace) -> int:
179
+ text = _read_markdown_input(args.file, "improve")
180
+ result = improve_product(parse(text))
181
+ if args.json:
182
+ print(outputs.render_improve_json(result))
183
+ elif args.template:
184
+ print(outputs.render_improve_template(result))
185
+ else:
186
+ print(outputs.render_improve_human(result))
187
+ # Advisory: a completed analysis always succeeds, with or without suggestions.
188
+ return EXIT_OK
189
+
190
+
176
191
  def build_parser() -> argparse.ArgumentParser:
177
192
  version_str = f"rac {__version__}"
178
193
 
@@ -276,7 +291,26 @@ def build_parser() -> argparse.ArgumentParser:
276
291
  )
277
292
  p_inspect.set_defaults(func=cmd_inspect)
278
293
 
279
- # Future command (rac improve <file>) will register here.
294
+ p_improve = sub.add_parser(
295
+ "improve",
296
+ help="Suggest missing sections (and templates) for an artifact.",
297
+ parents=[version_parent],
298
+ )
299
+ p_improve.add_argument(
300
+ "file",
301
+ help="A Markdown file, or '-' to read from stdin.",
302
+ )
303
+ improve_mode = p_improve.add_mutually_exclusive_group()
304
+ improve_mode.add_argument(
305
+ "--json", action="store_true", help="Emit JSON instead of human-readable text."
306
+ )
307
+ improve_mode.add_argument(
308
+ "--template",
309
+ action="store_true",
310
+ help="Emit Markdown templates for the missing sections.",
311
+ )
312
+ p_improve.set_defaults(func=cmd_improve)
313
+
280
314
  return parser
281
315
 
282
316
 
@@ -0,0 +1,84 @@
1
+ """Artifact improvement — deterministic, schema-driven guidance (ADR-002).
2
+
3
+ `rac improve <file>` is RAC's first *advisory* capability: it reports which
4
+ required and recommended sections an artifact is missing and can emit Markdown
5
+ templates for them. It is strictly read-only (REQ-004) and generates no content
6
+ beyond schema-derived placeholders — no AI, no rewriting.
7
+
8
+ Improvement depends only on the artifact *type* and a *schema comparison*
9
+ (:func:`rac.classification.missing_sections`); it never reaches into classification
10
+ confidence internals. v0.5.0 produces suggestions for Requirement artifacts only;
11
+ other known types and Unknown return no suggestions (the renderers explain why).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ from .artifacts import spec_for
19
+ from .classification import classify, missing_sections
20
+ from .models import Product
21
+ from .parser import parse, parse_file
22
+
23
+ # The single artifact type that v0.5.0 generates suggestions for. Written as a
24
+ # constant (not hard-coded throughout) so widening scope later is a one-line change.
25
+ SUPPORTED_TYPE = "requirement"
26
+
27
+
28
+ @dataclass
29
+ class ImprovementResult:
30
+ """Typed improvement analysis for one artifact (ADR-003).
31
+
32
+ Section names are stored normalized (e.g. ``"success metrics"``); renderers
33
+ format them. ``to_dict`` is the stable JSON contract (ADR-007):
34
+ ``{type, missing_required, missing_recommended}``.
35
+ """
36
+
37
+ type: str # classified artifact type, or "unknown"
38
+ missing_required: list[str]
39
+ missing_recommended: list[str]
40
+ # Reserved for future Unknown handling (e.g. "closest match" guidance). Always
41
+ # None in v0.5.0 and intentionally not serialized yet — additive later (ADR-007).
42
+ closest_type: str | None = None
43
+
44
+ @property
45
+ def supported(self) -> bool:
46
+ """True when this is a type ``improve`` generates suggestions for."""
47
+ return self.type == SUPPORTED_TYPE
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "type": self.type,
52
+ "missing_required": [_snake(s) for s in self.missing_required],
53
+ "missing_recommended": [_snake(s) for s in self.missing_recommended],
54
+ }
55
+
56
+
57
+ def _snake(section: str) -> str:
58
+ return section.replace(" ", "_")
59
+
60
+
61
+ def improve_product(product: Product) -> ImprovementResult:
62
+ """Analyze a parsed ``product`` and return improvement guidance."""
63
+ artifact_type = classify(product).type
64
+ if artifact_type != SUPPORTED_TYPE:
65
+ # Decision / other known types and Unknown: no suggestions in v0.5.0.
66
+ return ImprovementResult(
67
+ type=artifact_type, missing_required=[], missing_recommended=[]
68
+ )
69
+ spec = spec_for(SUPPORTED_TYPE)
70
+ assert spec is not None # the requirement spec always exists
71
+ missing_required, missing_recommended = missing_sections(product, spec)
72
+ return ImprovementResult(
73
+ type=artifact_type,
74
+ missing_required=missing_required,
75
+ missing_recommended=missing_recommended,
76
+ )
77
+
78
+
79
+ def improve_text(text: str) -> ImprovementResult:
80
+ return improve_product(parse(text))
81
+
82
+
83
+ def improve_file(path: str) -> ImprovementResult:
84
+ return improve_product(parse_file(path))
@@ -10,8 +10,9 @@ import json
10
10
  import sys
11
11
  from dataclasses import asdict
12
12
 
13
- from .artifacts import ARTIFACT_SPECS
13
+ from .artifacts import ARTIFACT_SPECS, spec_for
14
14
  from .classification import CONFIDENCE_THRESHOLD, TypeScore
15
+ from .improve import ImprovementResult
15
16
  from .ingest import IngestResult
16
17
  from .inspect import DirectoryInspection, InspectionResult
17
18
  from .models import Diff, Issue, Product
@@ -374,6 +375,75 @@ def render_dir_inspect_json(d: DirectoryInspection) -> str:
374
375
  return json.dumps(payload, indent=2)
375
376
 
376
377
 
378
+ # --- improve -----------------------------------------------------------------
379
+
380
+ # Shown when guidance cannot be produced. Ordering everywhere is required-first,
381
+ # then recommended (schema declaration order within each).
382
+ _UNKNOWN_MESSAGE = (
383
+ "Unable to generate improvement guidance.\n"
384
+ "Artifact type could not be determined."
385
+ )
386
+
387
+
388
+ def _unsupported_message(result: ImprovementResult) -> str:
389
+ """Generic guidance for a known but unsupported artifact type (e.g. Decision)."""
390
+ return (
391
+ f"Artifact Type: {result.type.title()}\n\n"
392
+ "Improvement guidance is not currently available for this artifact type."
393
+ )
394
+
395
+
396
+ def render_improve_human(result: ImprovementResult) -> str:
397
+ if result.type == "unknown":
398
+ return _UNKNOWN_MESSAGE
399
+ if not result.supported:
400
+ return _unsupported_message(result)
401
+
402
+ lines = [_bold(f"Artifact Type: {result.type.title()}"), ""]
403
+ if not result.missing_required and not result.missing_recommended:
404
+ lines.append("Nothing to improve — all expected sections present.")
405
+ return "\n".join(lines)
406
+
407
+ def block(title: str, names: list[str]) -> None:
408
+ lines.extend([_bold(title)])
409
+ if names:
410
+ lines.extend(f" - {s.title()}" for s in names)
411
+ else:
412
+ lines.append(" (none)")
413
+ lines.append("")
414
+
415
+ block("Missing Required:", result.missing_required)
416
+ block("Missing Recommended:", result.missing_recommended)
417
+ return "\n".join(lines).rstrip()
418
+
419
+
420
+ def render_improve_json(result: ImprovementResult) -> str:
421
+ return json.dumps(result.to_dict(), indent=2)
422
+
423
+
424
+ def render_improve_template(result: ImprovementResult) -> str:
425
+ """Emit Markdown templates for missing sections (required first)."""
426
+ if result.type == "unknown":
427
+ return _UNKNOWN_MESSAGE
428
+ if not result.supported:
429
+ return _unsupported_message(result)
430
+
431
+ missing = result.missing_required + result.missing_recommended
432
+ if not missing:
433
+ return "# Nothing to add — all expected sections present."
434
+
435
+ spec = spec_for(result.type)
436
+ descriptions = spec.descriptions if spec else {}
437
+ blocks: list[str] = []
438
+ for section in missing:
439
+ block = f"## {section.title()}\n\n_TODO_"
440
+ hint = descriptions.get(section)
441
+ if hint:
442
+ block += f"\n\n<!-- {hint} -->"
443
+ blocks.append(block)
444
+ return "\n\n".join(blocks) + "\n"
445
+
446
+
377
447
  # --- ingest ------------------------------------------------------------------
378
448
 
379
449
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: requirements-as-code
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: RAC — lint and diff product requirements written in Markdown.
5
5
  Author: tcballard
6
6
  License-Expression: MIT
@@ -616,6 +616,67 @@ usage errors (file not found, or a non-Markdown file — convert it with
616
616
 
617
617
  ---
618
618
 
619
+ ## Improve
620
+
621
+ Where `inspect` tells you *what an artifact is*, `improve` tells you *what to add
622
+ next*. It reports the required and recommended sections an artifact is missing —
623
+ **deterministically, from the schema, with no AI** (ADR-002) — and can emit
624
+ Markdown templates for them. `improve` is **advisory and read-only**: it never
625
+ modifies your files and never generates content beyond `_TODO_` placeholders.
626
+
627
+ ```bash
628
+ rac improve requirement.md
629
+ rac improve requirement.md --template # Markdown skeletons for missing sections
630
+ rac improve requirement.md --json
631
+ cat requirement.md | rac improve - # stdin
632
+ rac ingest prd.docx --stdout | rac improve -
633
+ ```
634
+
635
+ Default output:
636
+
637
+ ```text
638
+ Artifact Type: Requirement
639
+
640
+ Missing Required:
641
+ (none)
642
+
643
+ Missing Recommended:
644
+ - Risks
645
+ - Assumptions
646
+ ```
647
+
648
+ `--template` turns the gaps into a ready-to-paste skeleton (required sections
649
+ first, then recommended), with a short guidance hint per section:
650
+
651
+ ```markdown
652
+ ## Risks
653
+
654
+ _TODO_
655
+
656
+ <!-- Potential implementation, delivery, or adoption risks -->
657
+ ```
658
+
659
+ So you can go straight from `rac inspect requirement.md` to
660
+ `rac improve requirement.md --template` without consulting any documentation.
661
+
662
+ `--json` returns a stable contract (ADR-007):
663
+
664
+ ```json
665
+ {
666
+ "type": "requirement",
667
+ "missing_required": [],
668
+ "missing_recommended": ["risks", "assumptions"]
669
+ }
670
+ ```
671
+
672
+ v0.5.0 generates suggestions for **Requirement** artifacts. Other known types
673
+ (e.g. Decision) and Unknown documents return a short explanatory message instead.
674
+ `improve` is advisory: it exits `0` for any completed analysis (with or without
675
+ suggestions) and `2` for usage errors. The presence of suggestions never changes
676
+ the exit code.
677
+
678
+ ---
679
+
619
680
  ## Review (Planned)
620
681
 
621
682
  AI-assisted product review.
@@ -45,6 +45,7 @@ rac/classification.py
45
45
  rac/cli.py
46
46
  rac/diff.py
47
47
  rac/fs.py
48
+ rac/improve.py
48
49
  rac/ingest.py
49
50
  rac/inspect.py
50
51
  rac/models.py
@@ -62,6 +63,7 @@ tests/conftest.py
62
63
  tests/test_cli.py
63
64
  tests/test_decision_metadata.py
64
65
  tests/test_diff.py
66
+ tests/test_improve.py
65
67
  tests/test_ingest.py
66
68
  tests/test_inspect.py
67
69
  tests/test_parser.py
@@ -0,0 +1,213 @@
1
+ """Tests for artifact improvement (`rac improve`, v0.5.0).
2
+
3
+ Advisory, deterministic, schema-driven, read-only. Requirement artifacts get
4
+ missing-section suggestions; other known types and Unknown get explanatory
5
+ guidance. Exit code is always 0 for a completed analysis, 2 for usage errors.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import io
11
+ import json
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+
16
+ from rac.cli import main
17
+ from rac.improve import improve_file, improve_text
18
+
19
+ from conftest import fixture_path
20
+
21
+ # A requirement missing a *required* section (Requirements) but still classifying
22
+ # as a requirement — it keeps enough recommended sections to clear the threshold.
23
+ NO_REQUIREMENTS = (
24
+ "# Feature\n\n## Problem\n\np\n\n## Success Metrics\n\n- m\n\n## Risks\n\n- r\n"
25
+ )
26
+
27
+
28
+ # --- service layer ----------------------------------------------------------
29
+
30
+
31
+ def test_requirement_reports_missing_recommended():
32
+ result = improve_file(fixture_path("inspect", "requirement.md"))
33
+ assert result.type == "requirement"
34
+ assert result.missing_required == []
35
+ assert "risks" in result.missing_recommended
36
+ assert "assumptions" in result.missing_recommended
37
+
38
+
39
+ def test_requirement_reports_missing_required():
40
+ result = improve_text(NO_REQUIREMENTS)
41
+ assert result.type == "requirement"
42
+ assert result.missing_required == ["requirements"]
43
+ assert result.missing_recommended == ["assumptions"]
44
+
45
+
46
+ def test_complete_requirement_has_nothing_missing():
47
+ complete = (
48
+ "# Feature\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n\n"
49
+ "## Success Metrics\n\n- m\n\n## Risks\n\n- r\n\n## Assumptions\n\n- a\n"
50
+ )
51
+ result = improve_text(complete)
52
+ assert result.type == "requirement"
53
+ assert result.missing_required == []
54
+ assert result.missing_recommended == []
55
+
56
+
57
+ def test_unknown_artifact_yields_no_suggestions():
58
+ result = improve_file(fixture_path("inspect", "ambiguous.md"))
59
+ assert result.type == "unknown"
60
+ assert result.missing_required == []
61
+ assert result.missing_recommended == []
62
+
63
+
64
+ def test_decision_is_out_of_scope_for_v050():
65
+ result = improve_file(fixture_path("decision", "with_metadata.md"))
66
+ assert result.type == "decision"
67
+ assert not result.supported
68
+ assert result.missing_required == []
69
+
70
+
71
+ def test_improve_does_not_depend_on_typescore():
72
+ # Decoupling guard: improvement must not reach into classification scoring.
73
+ import inspect as _inspect
74
+
75
+ import rac.improve as improve_mod
76
+
77
+ src = _inspect.getsource(improve_mod)
78
+ assert "TypeScore" not in src
79
+ assert "score_artifacts" not in src
80
+
81
+
82
+ # --- JSON contract (ADR-007) ------------------------------------------------
83
+
84
+
85
+ def test_json_shape_is_stable(capsys):
86
+ rc = main(["improve", fixture_path("inspect", "requirement.md"), "--json"])
87
+ assert rc == 0
88
+ payload = json.loads(capsys.readouterr().out)
89
+ assert set(payload) == {"type", "missing_required", "missing_recommended"}
90
+ assert payload["type"] == "requirement"
91
+ # closest_type is reserved on the model but not serialized in v0.5.0.
92
+ assert "closest_type" not in payload
93
+
94
+
95
+ def test_json_uses_snake_cased_section_names(capsys):
96
+ # "success metrics" -> "success_metrics" when missing.
97
+ text = "# F\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n"
98
+ monkey_stdin(text)
99
+ rc = main(["improve", "-", "--json"])
100
+ assert rc == 0
101
+ payload = json.loads(capsys.readouterr().out)
102
+ assert "success_metrics" in payload["missing_recommended"]
103
+
104
+
105
+ def test_json_unknown_has_empty_arrays(capsys):
106
+ rc = main(["improve", fixture_path("inspect", "ambiguous.md"), "--json"])
107
+ assert rc == 0
108
+ payload = json.loads(capsys.readouterr().out)
109
+ assert payload["type"] == "unknown"
110
+ assert payload["missing_required"] == []
111
+ assert payload["missing_recommended"] == []
112
+
113
+
114
+ # --- template (REQ-003) -----------------------------------------------------
115
+
116
+
117
+ def test_template_emits_todo_and_guidance(capsys):
118
+ rc = main(["improve", fixture_path("inspect", "requirement.md"), "--template"])
119
+ assert rc == 0
120
+ out = capsys.readouterr().out
121
+ assert "## Risks" in out
122
+ assert "_TODO_" in out
123
+ assert "<!--" in out # schema guidance comment
124
+
125
+
126
+ def test_template_orders_required_before_recommended(capsys):
127
+ monkey_stdin(NO_REQUIREMENTS)
128
+ rc = main(["improve", "-", "--template"])
129
+ assert rc == 0
130
+ out = capsys.readouterr().out
131
+ assert out.index("## Requirements") < out.index("## Assumptions")
132
+
133
+
134
+ # --- human output -----------------------------------------------------------
135
+
136
+
137
+ def test_human_output_lists_missing(capsys):
138
+ rc = main(["improve", fixture_path("inspect", "requirement.md")])
139
+ assert rc == 0
140
+ out = capsys.readouterr().out
141
+ assert "Artifact Type: Requirement" in out
142
+ assert "Missing Recommended:" in out
143
+ assert "Risks" in out
144
+
145
+
146
+ def test_human_unknown_message(capsys):
147
+ rc = main(["improve", fixture_path("inspect", "ambiguous.md")])
148
+ assert rc == 0
149
+ out = capsys.readouterr().out
150
+ assert "Unable to generate improvement guidance." in out
151
+ assert "could not be determined" in out
152
+
153
+
154
+ def test_human_decision_is_generic_not_requirement_worded(capsys):
155
+ rc = main(["improve", fixture_path("decision", "with_metadata.md")])
156
+ assert rc == 0
157
+ out = capsys.readouterr().out
158
+ assert "Artifact Type: Decision" in out
159
+ assert "not currently available for this artifact type" in out
160
+ assert "Requirement" not in out # no hard-coded requirement wording
161
+
162
+
163
+ # --- pipelines (ADR-011) ----------------------------------------------------
164
+
165
+
166
+ def monkey_stdin(text: str) -> None:
167
+ import sys
168
+
169
+ sys.stdin = io.StringIO(text)
170
+
171
+
172
+ def test_stdin_matches_file(monkeypatch, capsys):
173
+ text = Path(fixture_path("inspect", "requirement.md")).read_text()
174
+ monkeypatch.setattr("sys.stdin", io.StringIO(text))
175
+ rc = main(["improve", "-", "--json"])
176
+ assert rc == 0
177
+ from_stdin = json.loads(capsys.readouterr().out)
178
+ assert from_stdin == improve_text(text).to_dict()
179
+
180
+
181
+ # --- usage errors (exit 2) --------------------------------------------------
182
+
183
+
184
+ def test_missing_file_exits_two():
185
+ with pytest.raises(SystemExit) as exc:
186
+ main(["improve", fixture_path("inspect", "does_not_exist.md")])
187
+ assert exc.value.code == 2
188
+
189
+
190
+ def test_non_markdown_file_exits_two(tmp_path):
191
+ bad = tmp_path / "notes.txt"
192
+ bad.write_text("hello")
193
+ with pytest.raises(SystemExit) as exc:
194
+ main(["improve", str(bad)])
195
+ assert exc.value.code == 2
196
+
197
+
198
+ def test_json_and_template_are_mutually_exclusive():
199
+ with pytest.raises(SystemExit): # argparse mutually-exclusive error
200
+ main(["improve", fixture_path("inspect", "requirement.md"), "--json", "--template"])
201
+
202
+
203
+ # --- read-only (REQ-004) ----------------------------------------------------
204
+
205
+
206
+ def test_improve_does_not_modify_the_file(tmp_path):
207
+ f = tmp_path / "req.md"
208
+ original = "# F\n\n## Problem\n\np\n\n## Requirements\n\n[REQ-001] x\n"
209
+ f.write_text(original)
210
+ before = f.stat().st_mtime_ns
211
+ main(["improve", str(f), "--template"])
212
+ assert f.read_text() == original
213
+ assert f.stat().st_mtime_ns == before