messagefoundry-toolkit 0.5.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.
- messagefoundry_toolkit/__init__.py +21 -0
- messagefoundry_toolkit/__main__.py +216 -0
- messagefoundry_toolkit/adr_analyze.py +345 -0
- messagefoundry_toolkit-0.5.0.dist-info/METADATA +56 -0
- messagefoundry_toolkit-0.5.0.dist-info/RECORD +9 -0
- messagefoundry_toolkit-0.5.0.dist-info/WHEEL +4 -0
- messagefoundry_toolkit-0.5.0.dist-info/entry_points.txt +2 -0
- messagefoundry_toolkit-0.5.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry_toolkit-0.5.0.dist-info/licenses/NOTICE +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors
|
|
3
|
+
"""MessageFoundry toolkit: the authoring and development commands, kept out of the engine wheel.
|
|
4
|
+
|
|
5
|
+
ADR 0201 (BACKLOG #1192, ASVS 15.2.3) moves every command ``messagefoundry.cli_surface.CLI_TIERS``
|
|
6
|
+
marks ``toolkit`` out of the engine's ``messagefoundry`` command and into this package, whose own
|
|
7
|
+
command is ``messagefoundry-toolkit``. A production install carries the engine alone, so it neither
|
|
8
|
+
includes this code nor exposes these commands.
|
|
9
|
+
|
|
10
|
+
The toolkit may import the engine. The engine must never import the toolkit, and
|
|
11
|
+
``tests/test_dependency_boundaries.py`` holds that direction with a text search over
|
|
12
|
+
``messagefoundry/``.
|
|
13
|
+
|
|
14
|
+
The distribution is ``messagefoundry-toolkit``, built from ``packaging/messagefoundry-toolkit/`` in
|
|
15
|
+
lockstep with the engine: same version, same tag. Dev and CI environments do not install it. The
|
|
16
|
+
engine's editable install already puts the repository root on the import path, so
|
|
17
|
+
``python -m messagefoundry_toolkit`` runs this checkout's copy. ADR 0201 section 1 says why an
|
|
18
|
+
editable install of this distribution would be a frozen copy instead.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors
|
|
3
|
+
"""Command-line entrypoint for the MessageFoundry toolkit (ADR 0201).
|
|
4
|
+
|
|
5
|
+
messagefoundry-toolkit adr-analyze --adr-dir docs/adr --json # ADR criteria->test coverage
|
|
6
|
+
|
|
7
|
+
In a checkout, run it as ``python -m messagefoundry_toolkit``. Each command here is a row that
|
|
8
|
+
``messagefoundry.cli_surface.CLI_TIERS`` marks ``toolkit``. The engine's ``messagefoundry`` command
|
|
9
|
+
does not register these rows, and refuses them with a line naming this command.
|
|
10
|
+
|
|
11
|
+
The process shell around dispatch is the engine's own, ``messagefoundry.cli_common.run_cli``: stream
|
|
12
|
+
hardening, the last-resort exception hooks, the redacting stderr log sink and the JSON error floor.
|
|
13
|
+
This module never imports ``messagefoundry.__main__``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
import unicodedata
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from importlib import metadata
|
|
23
|
+
|
|
24
|
+
from messagefoundry import __version__
|
|
25
|
+
from messagefoundry.cli_common import (
|
|
26
|
+
Dispatch,
|
|
27
|
+
HelpFormatter,
|
|
28
|
+
_emit_error,
|
|
29
|
+
_print_json,
|
|
30
|
+
_safe_print,
|
|
31
|
+
argv_wants_json,
|
|
32
|
+
run_cli,
|
|
33
|
+
)
|
|
34
|
+
from messagefoundry.cli_surface import TOOLKIT_COMMAND
|
|
35
|
+
from messagefoundry.console_streams import harden_console_streams
|
|
36
|
+
|
|
37
|
+
#: The two distributions whose versions must match (ADR 0201 section 1). The toolkit's distribution
|
|
38
|
+
#: and command share one name, held in ``cli_surface`` so the engine's refusal names the same one.
|
|
39
|
+
TOOLKIT_DISTRIBUTION = TOOLKIT_COMMAND
|
|
40
|
+
ENGINE_DISTRIBUTION = "messagefoundry"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv: list[str] | None = None) -> int:
|
|
44
|
+
# FIRST STATEMENT, as in every console entry point (BACKLOG #1875):
|
|
45
|
+
# tests/test_cp1252_console_safety.py reads it here. run_cli() hardens again, which is a no-op.
|
|
46
|
+
harden_console_streams()
|
|
47
|
+
args = sys.argv[1:] if argv is None else argv
|
|
48
|
+
mismatch = version_mismatch(metadata.version)
|
|
49
|
+
if mismatch is not None:
|
|
50
|
+
# Exit 2, the usage-error code, and not 1: no command ran, so no command's result is being
|
|
51
|
+
# reported. _emit_error keeps the JSON-XOR-text rule, and its own return value is 1.
|
|
52
|
+
_emit_error(mismatch, as_json=argv_wants_json(args))
|
|
53
|
+
return 2
|
|
54
|
+
return run_cli(args, _build_parser)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def version_mismatch(version_of: Callable[[str], str]) -> str | None:
|
|
58
|
+
"""The refusal line when the installed toolkit and engine differ in version, or None (AC-4).
|
|
59
|
+
|
|
60
|
+
``version_of`` is ``importlib.metadata.version``, passed in so a test can supply the metadata.
|
|
61
|
+
It compares METADATA WITH METADATA, never with the engine's live ``__version__``: the question
|
|
62
|
+
is which two distributions pip installed side by side. ``pip install -U messagefoundry``
|
|
63
|
+
upgrades the engine past the toolkit's ``==`` pin with only a resolver warning and exit 0, so
|
|
64
|
+
the pin alone does not hold the pair together.
|
|
65
|
+
|
|
66
|
+
With no toolkit metadata the check is skipped. That is every dev and CI environment, where the
|
|
67
|
+
toolkit is imported from the checkout beside the engine and the two cannot differ. With toolkit
|
|
68
|
+
metadata and no engine metadata, the pair cannot be checked, so the line refuses it.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
toolkit = version_of(TOOLKIT_DISTRIBUTION)
|
|
72
|
+
except metadata.PackageNotFoundError:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
engine = version_of(ENGINE_DISTRIBUTION)
|
|
76
|
+
except metadata.PackageNotFoundError:
|
|
77
|
+
engine = "not installed"
|
|
78
|
+
if engine == toolkit:
|
|
79
|
+
return None
|
|
80
|
+
return (
|
|
81
|
+
f"{TOOLKIT_DISTRIBUTION} {toolkit} runs only beside {ENGINE_DISTRIBUTION} {toolkit}, but "
|
|
82
|
+
f"the installed {ENGINE_DISTRIBUTION} is {engine}. Install the two at one version."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_parser() -> tuple[argparse.ArgumentParser, Dispatch]:
|
|
87
|
+
"""Build the toolkit's argument parser, and return it with the dispatch map.
|
|
88
|
+
|
|
89
|
+
Building has no side effect, as with the engine's builder, so a test can read the toolkit's
|
|
90
|
+
command surface without running ``main()``. The map returned is :data:`_DISPATCH` itself.
|
|
91
|
+
"""
|
|
92
|
+
parser = argparse.ArgumentParser(
|
|
93
|
+
prog=TOOLKIT_COMMAND,
|
|
94
|
+
description=__doc__,
|
|
95
|
+
formatter_class=HelpFormatter,
|
|
96
|
+
allow_abbrev=False, # the pre-parse refusal reads --help and --version by exact spelling
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument("--version", action="version", version=f"{TOOLKIT_COMMAND} {__version__}")
|
|
99
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
100
|
+
|
|
101
|
+
adr_analyze = sub.add_parser(
|
|
102
|
+
"adr-analyze",
|
|
103
|
+
help="advisory spec-driven ADR coverage: acceptance-criteria->test links, missing criteria, "
|
|
104
|
+
"open clarifications (Secure Development Standards section 5)",
|
|
105
|
+
)
|
|
106
|
+
adr_analyze.add_argument(
|
|
107
|
+
"--adr-dir",
|
|
108
|
+
default="docs/adr",
|
|
109
|
+
help="ADR directory (default: docs/adr). Exits 2, with or without --strict, if it is "
|
|
110
|
+
"missing, is not a directory, or holds no ADR",
|
|
111
|
+
)
|
|
112
|
+
adr_analyze.add_argument(
|
|
113
|
+
"--repo-root",
|
|
114
|
+
default=None,
|
|
115
|
+
help="root for resolving test/fixture refs (default: adr-dir/../..; required when adr-dir "
|
|
116
|
+
"sits directly under a drive or filesystem root)",
|
|
117
|
+
)
|
|
118
|
+
adr_analyze.add_argument(
|
|
119
|
+
"--strict",
|
|
120
|
+
action="store_true",
|
|
121
|
+
help="exit 1 if any acceptance-criterion test ref is missing",
|
|
122
|
+
)
|
|
123
|
+
adr_analyze.add_argument("--json", action="store_true", help="emit JSON")
|
|
124
|
+
|
|
125
|
+
return parser, _DISPATCH
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _adr_analyze(args: argparse.Namespace) -> int:
|
|
129
|
+
"""Advisory spec-driven ADR coverage (Secure Development Standards §5). Reports acceptance-
|
|
130
|
+
criteria→test link coverage, Accepted ADRs missing criteria, and open ``- [ ]`` clarifications.
|
|
131
|
+
|
|
132
|
+
Two exit codes, and which one a condition gets is the point of the split. A *finding* is
|
|
133
|
+
advisory: a missing linked test/fixture, or one outside the repository root, exits 0, or 1
|
|
134
|
+
under ``--strict``. An *absent corpus*, or an ADR directory with no grandparent to default
|
|
135
|
+
the repository root to —
|
|
136
|
+
:attr:`~messagefoundry_toolkit.adr_analyze.AnalysisResult.error`, defined at
|
|
137
|
+
:func:`~messagefoundry_toolkit.adr_analyze.analyze_adrs` — exits **2 with or without
|
|
138
|
+
``--strict``**, because the analyzer never ran. 2 and not 1 keeps "could not start" apart from
|
|
139
|
+
"ran and reported a problem", the same split the engine's ``_emit_store_open_error`` spends 2 on;
|
|
140
|
+
and not 0 because this subcommand is otherwise unfailable by default, so a withdrawn ADR set
|
|
141
|
+
would silently turn a failing report into a passing one.
|
|
142
|
+
|
|
143
|
+
That split is between this command's own codes. It does **not** separate 2 from argparse's own
|
|
144
|
+
usage-error 2, so a caller that must tell a withdrawn corpus from a mistyped flag has to read
|
|
145
|
+
the output, not the code. Every subcommand here inherits that, ``--json`` disambiguates it, and
|
|
146
|
+
widening it was not worth a third code."""
|
|
147
|
+
from messagefoundry_toolkit.adr_analyze import analyze_adrs
|
|
148
|
+
|
|
149
|
+
result = analyze_adrs(args.adr_dir, repo_root=args.repo_root)
|
|
150
|
+
if args.json:
|
|
151
|
+
_print_json(result.to_json(), compact=True)
|
|
152
|
+
if result.error is not None:
|
|
153
|
+
# JSON on stdout XOR the human line on stderr. Emitting both would reorder under `2>&1`: a
|
|
154
|
+
# piped stdout is block-buffered and stderr is not, so the error line would land ahead of
|
|
155
|
+
# the JSON and break the parse it was meant to protect. The JSON body is the full report
|
|
156
|
+
# with `error` inside it, and so is NOT _emit_store_open_error's bare {"error": ...}: `ok`
|
|
157
|
+
# has to stay readable for a consumer that branches on it and nothing else.
|
|
158
|
+
if not args.json:
|
|
159
|
+
print(f"error: {result.error}", file=sys.stderr) # not _safe_print; see its docstring
|
|
160
|
+
return 2
|
|
161
|
+
if not args.json:
|
|
162
|
+
with_criteria = sum(1 for r in result.reports if r.has_criteria)
|
|
163
|
+
_print_record_line(
|
|
164
|
+
f"ADRs analyzed: {len(result.reports)} ({with_criteria} with acceptance criteria)"
|
|
165
|
+
)
|
|
166
|
+
for adr in result.accepted_without_criteria:
|
|
167
|
+
_print_record_line(f" recommend: {adr} is Accepted with no acceptance-criteria block")
|
|
168
|
+
for adr, ref in result.coverage_gaps:
|
|
169
|
+
_print_record_line(f" COVERAGE GAP: {adr} links a missing test/fixture: {ref}")
|
|
170
|
+
for adr, ref in result.outside_refs:
|
|
171
|
+
_print_record_line(
|
|
172
|
+
f" OUTSIDE REPO: {adr} links a path outside the repository root, "
|
|
173
|
+
f"not checked: {ref}"
|
|
174
|
+
)
|
|
175
|
+
for adr, item in result.open_clarifications:
|
|
176
|
+
_print_record_line(f" clarify: {adr} - open item: {item}")
|
|
177
|
+
_print_record_line(
|
|
178
|
+
"ok" if result.ok else "coverage gaps or links outside the repository found (advisory)"
|
|
179
|
+
)
|
|
180
|
+
return 1 if args.strict and not result.ok else 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
#: Unicode categories escaped before record text reaches a terminal: C0 and C1 controls (an ANSI
|
|
184
|
+
#: escape, a bell, a NUL), format characters (a bidirectional override that reorders the line) and
|
|
185
|
+
#: the line and paragraph separators. An ADR is read as data, and a terminal acts on these.
|
|
186
|
+
#: ``messagefoundry.controlchars.scrub_control_chars`` is not reused: its alphabet is C0 and DEL
|
|
187
|
+
#: only, pinned that way for byte-oriented sinks, and so it passes C1 and the bidi overrides.
|
|
188
|
+
_ESCAPED_CATEGORIES = frozenset({"Cc", "Cf", "Zl", "Zp"})
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _print_record_line(line: str) -> None:
|
|
192
|
+
"""``_safe_print`` a line that carries record text, with each character a terminal would act
|
|
193
|
+
on written as its Python escape (BACKLOG #2516). Every human line goes through here, so a new
|
|
194
|
+
one cannot forget the escape.
|
|
195
|
+
|
|
196
|
+
Local to the toolkit on purpose. ``_safe_print`` serves every engine command too, and its job
|
|
197
|
+
is the console codec, not what the line says. The JSON output needs nothing: ``json.dumps``
|
|
198
|
+
already escapes every control and non-ASCII character.
|
|
199
|
+
|
|
200
|
+
A backslash is doubled as well, so a record that spells ``\\x1b`` out in text cannot pass for
|
|
201
|
+
one that holds the escape character itself."""
|
|
202
|
+
_safe_print(
|
|
203
|
+
"".join(
|
|
204
|
+
ascii(ch)[1:-1] if ch == "\\" or unicodedata.category(ch) in _ESCAPED_CATEGORIES else ch
|
|
205
|
+
for ch in line
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_DISPATCH: dict[str, Callable[[argparse.Namespace], int]] = {
|
|
211
|
+
"adr-analyze": _adr_analyze,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors
|
|
3
|
+
"""``messagefoundry-toolkit adr-analyze`` — advisory spec-driven coverage report over the ADRs.
|
|
4
|
+
|
|
5
|
+
The **analyze** half of the Secure Development Standards §5 spec-driven recommendations (R3): scan the
|
|
6
|
+
Architecture Decision Records and report, **advisory-only** (never blocks a commit by default):
|
|
7
|
+
|
|
8
|
+
* **Acceptance-criteria coverage** — for each ADR carrying an ``## Acceptance Criteria`` block (EARS,
|
|
9
|
+
per the ADR ``TEMPLATE.md`` / R1), the test/fixture each criterion links to (``→ tests/…``), and
|
|
10
|
+
whether that file exists on disk. A *coverage gap* is a criterion whose linked test is missing.
|
|
11
|
+
A link whose path climbs out of the repository root with ``..``, or names a DOS device such as
|
|
12
|
+
``NUL`` or ``nul.py``, is reported as outside the repository on every platform, never probed.
|
|
13
|
+
* **Missing criteria** — an ``Accepted`` ADR with no acceptance-criteria block (recommended to add).
|
|
14
|
+
* **Open clarifications** — unchecked ``- [ ]`` task items (the "clarify" step): questions that
|
|
15
|
+
should be resolved before an ADR flips to ``Accepted``.
|
|
16
|
+
|
|
17
|
+
Pure (filesystem reads only). Its **findings** are advisory: :attr:`AnalysisResult.ok` is
|
|
18
|
+
informational and the CLI exits 0 unless ``--strict`` is passed, so it adds no new blocking gate —
|
|
19
|
+
the §5 practices are recommended, not required. One condition is not a finding and is never
|
|
20
|
+
advisory — an absent corpus; :func:`analyze_adrs` defines it and says why.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import posixpath
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AcceptanceCriterion",
|
|
33
|
+
"AdrReport",
|
|
34
|
+
"AnalysisResult",
|
|
35
|
+
"analyze_adrs",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# How an ADR file is recognised. Quoted verbatim in the "nothing matched" error so the message and
|
|
39
|
+
# the code state the same rule -- it is looser than the ``NNNN-`` naming convention it implements.
|
|
40
|
+
_DISCOVERY_GLOB = "[0-9]*.md"
|
|
41
|
+
|
|
42
|
+
# A reference inside an Acceptance-Criteria block pointing at a test or fixture, e.g.
|
|
43
|
+
# ``tests/test_foo.py::test_bar`` or ``fixtures/IB_ACME/adt.hl7``. The ``::node`` pytest selector is
|
|
44
|
+
# captured but dropped for the on-disk existence check.
|
|
45
|
+
_REF_RE = re.compile(
|
|
46
|
+
r"(?:tests|fixtures|samples|harness)/[A-Za-z0-9_./\-]+(?:::[A-Za-z0-9_\-\[\]]+)?"
|
|
47
|
+
)
|
|
48
|
+
_STATUS_RE = re.compile(
|
|
49
|
+
r"status[^A-Za-z]*\b(Proposed|Accepted|Superseded|Rejected|Reserved|Dropped)\b", re.IGNORECASE
|
|
50
|
+
)
|
|
51
|
+
# Both run on an RSTRIPPED line, so the capture starts at a non-space and runs to the end. The old
|
|
52
|
+
# forms, ``\s+(.*\S)\s*$`` on the raw line, retried every split of a whitespace-only tail and took
|
|
53
|
+
# time quadratic in its length (BACKLOG #2516). ``rstrip`` and ``\s`` agree on what whitespace is.
|
|
54
|
+
_UNCHECKED_RE = re.compile(r"^\s*[-*]\s+\[ \]\s+(\S.*)$")
|
|
55
|
+
_HEADING_RE = re.compile(r"^#{1,6}\s+(\S.*)$")
|
|
56
|
+
_BULLET_RE = re.compile(r"^\s*[-*]\s+\S")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AcceptanceCriterion:
|
|
61
|
+
"""One acceptance-criterion bullet from an ADR's ``## Acceptance Criteria`` block."""
|
|
62
|
+
|
|
63
|
+
text: str
|
|
64
|
+
test_refs: list[str] = field(default_factory=list)
|
|
65
|
+
missing_refs: list[str] = field(default_factory=list)
|
|
66
|
+
#: Refs whose path climbs out of the repository root. Never probed, so never in ``missing_refs``.
|
|
67
|
+
outside_refs: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def covered(self) -> bool:
|
|
71
|
+
"""Covered iff it links ≥1 test/fixture, none missing on disk and none outside the root."""
|
|
72
|
+
return bool(self.test_refs) and not self.missing_refs and not self.outside_refs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class AdrReport:
|
|
77
|
+
"""The spec-driven analysis of a single ADR file."""
|
|
78
|
+
|
|
79
|
+
path: str
|
|
80
|
+
adr_id: str
|
|
81
|
+
title: str
|
|
82
|
+
status: str
|
|
83
|
+
criteria: list[AcceptanceCriterion] = field(default_factory=list)
|
|
84
|
+
open_clarifications: list[str] = field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def accepted(self) -> bool:
|
|
88
|
+
return self.status.lower() == "accepted"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def has_criteria(self) -> bool:
|
|
92
|
+
return bool(self.criteria)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def coverage_gaps(self) -> list[str]:
|
|
96
|
+
return [ref for c in self.criteria for ref in c.missing_refs]
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def outside_refs(self) -> list[str]:
|
|
100
|
+
return [ref for c in self.criteria for ref in c.outside_refs]
|
|
101
|
+
|
|
102
|
+
def to_json(self) -> dict[str, object]:
|
|
103
|
+
return {
|
|
104
|
+
"path": self.path,
|
|
105
|
+
"adr_id": self.adr_id,
|
|
106
|
+
"title": self.title,
|
|
107
|
+
"status": self.status,
|
|
108
|
+
"criteria": [
|
|
109
|
+
{
|
|
110
|
+
"text": c.text,
|
|
111
|
+
"test_refs": c.test_refs,
|
|
112
|
+
"missing_refs": c.missing_refs,
|
|
113
|
+
"outside_refs": c.outside_refs,
|
|
114
|
+
}
|
|
115
|
+
for c in self.criteria
|
|
116
|
+
],
|
|
117
|
+
"open_clarifications": self.open_clarifications,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class AnalysisResult:
|
|
123
|
+
"""The whole-ADR-set report.
|
|
124
|
+
|
|
125
|
+
``error`` is a human-readable line naming the directory when there was no corpus to analyze,
|
|
126
|
+
or no repository root to check its links against, and ``None`` otherwise;
|
|
127
|
+
:func:`analyze_adrs` sets it and gives the reasoning.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
reports: list[AdrReport]
|
|
131
|
+
error: str | None = None
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def coverage_gaps(self) -> list[tuple[str, str]]:
|
|
135
|
+
"""``(adr_id, missing_ref)`` for every acceptance-criterion test link that does not exist."""
|
|
136
|
+
return [(r.adr_id, ref) for r in self.reports for ref in r.coverage_gaps]
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def outside_refs(self) -> list[tuple[str, str]]:
|
|
140
|
+
"""``(adr_id, ref)`` for every test link whose path leaves the repository root."""
|
|
141
|
+
return [(r.adr_id, ref) for r in self.reports for ref in r.outside_refs]
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def accepted_without_criteria(self) -> list[str]:
|
|
145
|
+
return [r.adr_id for r in self.reports if r.accepted and not r.has_criteria]
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def open_clarifications(self) -> list[tuple[str, str]]:
|
|
149
|
+
return [(r.adr_id, item) for r in self.reports for item in r.open_clarifications]
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def ok(self) -> bool:
|
|
153
|
+
"""True iff a corpus was analyzed and every acceptance-criterion link was found inside it."""
|
|
154
|
+
return self.error is None and not self.coverage_gaps and not self.outside_refs
|
|
155
|
+
|
|
156
|
+
def to_json(self) -> dict[str, object]:
|
|
157
|
+
return {
|
|
158
|
+
"ok": self.ok,
|
|
159
|
+
"error": self.error,
|
|
160
|
+
"adrs": [r.to_json() for r in self.reports],
|
|
161
|
+
"coverage_gaps": [{"adr": a, "ref": ref} for a, ref in self.coverage_gaps],
|
|
162
|
+
"outside_refs": [{"adr": a, "ref": ref} for a, ref in self.outside_refs],
|
|
163
|
+
"accepted_without_criteria": self.accepted_without_criteria,
|
|
164
|
+
"open_clarifications": [{"adr": a, "item": i} for a, i in self.open_clarifications],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _capture(pattern: re.Pattern[str], line: str) -> str | None:
|
|
169
|
+
"""``pattern``'s capture over the rstripped ``line``, or None when it does not match.
|
|
170
|
+
|
|
171
|
+
The capture has no surrounding whitespace, so callers need not strip it."""
|
|
172
|
+
m = pattern.match(line.rstrip())
|
|
173
|
+
return m.group(1) if m else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _sections(text: str) -> dict[str, list[str]]:
|
|
177
|
+
"""Split markdown into ``{lowercased-heading: body-lines}`` (any heading level)."""
|
|
178
|
+
sections: dict[str, list[str]] = {}
|
|
179
|
+
current: str | None = None
|
|
180
|
+
for line in text.splitlines():
|
|
181
|
+
heading = _capture(_HEADING_RE, line)
|
|
182
|
+
if heading is not None:
|
|
183
|
+
current = heading.lower()
|
|
184
|
+
sections.setdefault(current, [])
|
|
185
|
+
elif current is not None:
|
|
186
|
+
sections[current].append(line)
|
|
187
|
+
return sections
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _status(text: str) -> str:
|
|
191
|
+
m = _STATUS_RE.search(text)
|
|
192
|
+
return m.group(1).capitalize() if m else "Unknown"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _title(text: str, fallback: str) -> str:
|
|
196
|
+
for line in text.splitlines():
|
|
197
|
+
heading = _capture(_HEADING_RE, line)
|
|
198
|
+
if heading is not None:
|
|
199
|
+
return heading
|
|
200
|
+
return fallback
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
#: The DOS device names. Windows opens the device for one of these wherever it sits in a path.
|
|
204
|
+
#: ``_REF_RE`` admits ASCII only, so the superscript-digit ``COM`` and ``LPT`` forms and the ``$``
|
|
205
|
+
#: names such as ``CONIN$`` cannot reach :func:`_inside`, and are not listed.
|
|
206
|
+
_DOS_DEVICES = frozenset(
|
|
207
|
+
{"CON", "PRN", "AUX", "NUL"} | {f"{name}{n}" for name in ("COM", "LPT") for n in range(1, 10)}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _inside(ref_path: str) -> str | None:
|
|
212
|
+
"""A ref's path normalised, or None when it leaves the repository root.
|
|
213
|
+
|
|
214
|
+
Decided on the text alone, the same way on every host, so a ref that escapes is never probed.
|
|
215
|
+
``_REF_RE`` admits only ``/`` as a separator and no drive, colon or leading slash, which leaves
|
|
216
|
+
two ways out. One is ``..``. The other is a component whose stem, the part before its first
|
|
217
|
+
dot with trailing dots and spaces dropped, is a DOS device name in any case: ``tests/NUL``,
|
|
218
|
+
``tests/nul.py`` and ``fixtures/con.hl7`` are all outside. Windows itself disagrees by version
|
|
219
|
+
on a name with an extension -- windows-2022 opens the device for ``nul.py`` and newer releases
|
|
220
|
+
do not -- so the rule takes the older, wider reading rather than asking the host. A name that
|
|
221
|
+
only CONTAINS one stays inside: ``tests/null.py``, ``tests/console.py``, ``tests/com10.py``,
|
|
222
|
+
``tests/my_nul.py``. The normalised form is what gets probed, so ``tests/sub/../x`` means the
|
|
223
|
+
same on every OS. A symbolic link inside the root is still followed; this reads the text, not
|
|
224
|
+
the tree.
|
|
225
|
+
"""
|
|
226
|
+
norm = posixpath.normpath(ref_path)
|
|
227
|
+
if norm == ".." or norm.startswith("../"):
|
|
228
|
+
return None
|
|
229
|
+
for part in norm.split("/"):
|
|
230
|
+
if part.split(".", 1)[0].rstrip(". ").upper() in _DOS_DEVICES:
|
|
231
|
+
return None
|
|
232
|
+
return norm
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _criteria(lines: list[str], repo_root: Path) -> list[AcceptanceCriterion]:
|
|
236
|
+
"""Group the Acceptance-Criteria body into bullet items; an item is a criterion iff it contains
|
|
237
|
+
``SHALL`` (the EARS keyword), filtering out the blockquote legend. Resolve each ``→`` test ref."""
|
|
238
|
+
items: list[list[str]] = []
|
|
239
|
+
for line in lines:
|
|
240
|
+
if line.lstrip().startswith(">"):
|
|
241
|
+
continue # the EARS legend blockquote — not a criterion
|
|
242
|
+
if _BULLET_RE.match(line):
|
|
243
|
+
items.append([line])
|
|
244
|
+
elif items and line.strip():
|
|
245
|
+
items[-1].append(line) # a continuation line of the current bullet (e.g. the → ref)
|
|
246
|
+
out: list[AcceptanceCriterion] = []
|
|
247
|
+
for item in items:
|
|
248
|
+
blob = "\n".join(item)
|
|
249
|
+
if "SHALL" not in blob.upper():
|
|
250
|
+
continue
|
|
251
|
+
text = item[0].strip().lstrip("-*").strip()
|
|
252
|
+
refs: list[str] = []
|
|
253
|
+
for m in _REF_RE.finditer(blob):
|
|
254
|
+
ref = m.group(0)
|
|
255
|
+
if ref not in refs:
|
|
256
|
+
refs.append(ref)
|
|
257
|
+
outside: list[str] = []
|
|
258
|
+
missing: list[str] = []
|
|
259
|
+
for ref in refs:
|
|
260
|
+
ref_path = _inside(ref.split("::", 1)[0])
|
|
261
|
+
if ref_path is None:
|
|
262
|
+
outside.append(ref)
|
|
263
|
+
elif not (repo_root / ref_path).exists():
|
|
264
|
+
missing.append(ref)
|
|
265
|
+
out.append(
|
|
266
|
+
AcceptanceCriterion(
|
|
267
|
+
text=text, test_refs=refs, missing_refs=missing, outside_refs=outside
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _clarifications(text: str) -> list[str]:
|
|
274
|
+
return [
|
|
275
|
+
item for line in text.splitlines() if (item := _capture(_UNCHECKED_RE, line)) is not None
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _parse_adr(path: Path, repo_root: Path) -> AdrReport:
|
|
280
|
+
text = path.read_text(encoding="utf-8")
|
|
281
|
+
return AdrReport(
|
|
282
|
+
path=str(path),
|
|
283
|
+
adr_id=path.stem.split("-", 1)[0],
|
|
284
|
+
title=_title(text, path.stem),
|
|
285
|
+
status=_status(text),
|
|
286
|
+
criteria=_criteria(_sections(text).get("acceptance criteria", []), repo_root),
|
|
287
|
+
open_clarifications=_clarifications(text),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def analyze_adrs(adr_dir: str | Path, repo_root: str | Path | None = None) -> AnalysisResult:
|
|
292
|
+
"""Analyze every ``NNNN-*.md`` ADR under ``adr_dir`` (README/TEMPLATE are skipped).
|
|
293
|
+
|
|
294
|
+
``repo_root`` anchors the on-disk existence check for each ``→`` test/fixture reference; it
|
|
295
|
+
defaults to two levels above ``adr_dir`` (i.e. the repo root for the standard ``docs/adr`` layout).
|
|
296
|
+
Where ``adr_dir`` has no such grandparent, the default is refused through
|
|
297
|
+
:attr:`AnalysisResult.error`, which then names ``--repo-root`` instead of the corpus.
|
|
298
|
+
|
|
299
|
+
**AN ABSENT CORPUS IS AN ERROR, NOT AN EMPTY CLEAN RUN.** ``Path.glob`` yields nothing and
|
|
300
|
+
raises nothing for a directory that does not exist, so a missing ADR directory -- or one left
|
|
301
|
+
holding only its ``README.md`` and ``TEMPLATE.md`` scaffolding -- used to produce zero reports
|
|
302
|
+
and an :attr:`AnalysisResult.ok` of True. A check that cannot fail is worse than no check: it
|
|
303
|
+
would turn a withdrawn ADR set into a silent pass. So a path that does not exist, is not a
|
|
304
|
+
directory, or matches no ADR sets :attr:`AnalysisResult.error` -- a line naming the directory
|
|
305
|
+
-- and clears ``ok``; the CLI spends exit 2 on it whether or not ``--strict`` is passed.
|
|
306
|
+
|
|
307
|
+
**The error line says what was looked for, not why nothing was found.** ``Path.exists`` and
|
|
308
|
+
``Path.glob`` both swallow ``OSError``, so a directory the process cannot read is
|
|
309
|
+
indistinguishable here from one that is absent or genuinely empty. A message naming a cause
|
|
310
|
+
the code cannot observe would send an operator to re-create a directory that is already there,
|
|
311
|
+
so each line below stays on the observation and admits the unreadable case.
|
|
312
|
+
"""
|
|
313
|
+
adr_path = Path(adr_dir)
|
|
314
|
+
# Lexical ``abspath`` and not ``resolve()``: it makes the relative ``docs/adr`` default useful
|
|
315
|
+
# to an operator, and collapses any ``..`` they typed, without rewriting a path handed in
|
|
316
|
+
# through a symlink into its target.
|
|
317
|
+
shown = os.path.abspath(adr_path)
|
|
318
|
+
if not adr_path.exists():
|
|
319
|
+
return AnalysisResult(reports=[], error=f"no ADR directory found or readable at {shown}")
|
|
320
|
+
if not adr_path.is_dir():
|
|
321
|
+
return AnalysisResult(reports=[], error=f"the ADR path is not a directory: {shown}")
|
|
322
|
+
# ``is_file()`` because the glob matches a directory named like an ADR too, and handing one to
|
|
323
|
+
# ``_parse_adr`` raises where the whole point here is a reported error.
|
|
324
|
+
files = sorted(f for f in adr_path.glob(_DISCOVERY_GLOB) if f.is_file())
|
|
325
|
+
if not files:
|
|
326
|
+
return AnalysisResult(
|
|
327
|
+
reports=[], error=f"no file matching {_DISCOVERY_GLOB} found in {shown}"
|
|
328
|
+
)
|
|
329
|
+
if repo_root is not None:
|
|
330
|
+
root = Path(repo_root)
|
|
331
|
+
else:
|
|
332
|
+
# An ADR dir directly under a drive or filesystem root has no grandparent, and indexing
|
|
333
|
+
# ``parents[1]`` there raised IndexError (BACKLOG #2516). No default is right for it, so
|
|
334
|
+
# refuse and name the flag rather than guess at a root to probe.
|
|
335
|
+
resolved = adr_path.resolve()
|
|
336
|
+
if len(resolved.parents) < 2:
|
|
337
|
+
# Name the resolved path too: through a link, it is the one with no grandparent.
|
|
338
|
+
via = "" if str(resolved) == shown else f" (resolved: {resolved})"
|
|
339
|
+
return AnalysisResult(
|
|
340
|
+
reports=[],
|
|
341
|
+
error=f"no directory two levels above {shown}{via} to use as the repository "
|
|
342
|
+
"root; pass --repo-root",
|
|
343
|
+
)
|
|
344
|
+
root = resolved.parents[1]
|
|
345
|
+
return AnalysisResult(reports=[_parse_adr(f, root) for f in files])
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: messagefoundry-toolkit
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Authoring and development tooling for MessageFoundry, kept out of the engine wheel (ADR 0201)
|
|
5
|
+
Project-URL: Homepage, https://messagefoundry.org/
|
|
6
|
+
Project-URL: Source, https://github.com/MEFORORG/MessageFoundry
|
|
7
|
+
Project-URL: Contributing, https://github.com/MEFORORG/MessageFoundry/blob/main/CONTRIBUTING.md
|
|
8
|
+
Author: MessageFoundry Foundation, LLC and contributors
|
|
9
|
+
License-Expression: AGPL-3.0-or-later
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Keywords: authoring,development,healthcare,hl7,integration,tooling
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
17
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Communications
|
|
22
|
+
Classifier: Topic :: Software Development
|
|
23
|
+
Requires-Python: >=3.14
|
|
24
|
+
Requires-Dist: messagefoundry==0.5.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# messagefoundry-toolkit
|
|
28
|
+
|
|
29
|
+
The authoring and development commands for
|
|
30
|
+
[**MessageFoundry**](https://github.com/MEFORORG/MessageFoundry), the open-source healthcare
|
|
31
|
+
integration engine (HL7 v2.x and more), kept out of the engine wheel.
|
|
32
|
+
|
|
33
|
+
A production host runs the engine alone. The engine's `messagefoundry` command carries only what a
|
|
34
|
+
deployed engine needs to run, operate and check itself. The commands that help you write and test a
|
|
35
|
+
configuration live here instead, behind their own command, `messagefoundry-toolkit`. The design and
|
|
36
|
+
the reasons are in
|
|
37
|
+
[ADR 0201](https://github.com/MEFORORG/MessageFoundry/blob/main/docs/adr/0201-a-messagefoundry-toolkit-distribution-carries-the-authoring-and-development-tooling-out-of-the-engine-wheel.md).
|
|
38
|
+
|
|
39
|
+
> Released **in lockstep with the engine**: the toolkit and the engine carry the same version, and
|
|
40
|
+
> the toolkit requires the engine at exactly that version. If the two installed versions ever differ,
|
|
41
|
+
> the toolkit refuses to run and names both.
|
|
42
|
+
|
|
43
|
+
Install it on an authoring machine, beside the engine and at the engine's version. A deploy does not
|
|
44
|
+
need it, and a production host is better without it.
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
messagefoundry-toolkit adr-analyze --adr-dir docs/adr --json # ADR acceptance-criteria coverage
|
|
50
|
+
messagefoundry-toolkit --help # every toolkit command
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
From a source checkout, run `python -m messagefoundry_toolkit` instead.
|
|
54
|
+
|
|
55
|
+
The toolkit's commands move out of the engine one at a time. A command that has moved is refused by
|
|
56
|
+
the engine's `messagefoundry` command, with a line naming `messagefoundry-toolkit <command>`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
messagefoundry_toolkit/__init__.py,sha256=s66NDjEkwGOD6iFNxgFq4Sdp0cFGXzWFWDVPWamJVpM,1229
|
|
2
|
+
messagefoundry_toolkit/__main__.py,sha256=IJJsqZhsRay2k8--J-ctPsXRnj3cQ_nuCVvej8iyx2U,10301
|
|
3
|
+
messagefoundry_toolkit/adr_analyze.py,sha256=AzquK2-QYHqC-e3ERPqyCevb2K86kSVivxyjb1x3Bv4,15403
|
|
4
|
+
messagefoundry_toolkit-0.5.0.dist-info/METADATA,sha256=eMLCHZpVvknG9inYaN5ltMucD-JVEdGpUVvG46eGfzk,2756
|
|
5
|
+
messagefoundry_toolkit-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
messagefoundry_toolkit-0.5.0.dist-info/entry_points.txt,sha256=-ckmYTCqssV_KKRnqCwjiP5mbxuOIZ2ItHXvyfmvpjc,80
|
|
7
|
+
messagefoundry_toolkit-0.5.0.dist-info/licenses/LICENSE,sha256=jVa0BUaKrRH4erV2P5AeJ24I2WRv9chIGxditreJ6e0,34524
|
|
8
|
+
messagefoundry_toolkit-0.5.0.dist-info/licenses/NOTICE,sha256=Emp1QFl9pG7lwz68sH0SLRLfWlQjAILmiTWeokcZW9I,1823
|
|
9
|
+
messagefoundry_toolkit-0.5.0.dist-info/RECORD,,
|