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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ messagefoundry-toolkit = messagefoundry_toolkit.__main__:main