tac-validate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ """TAC parse gate and business-rule pack (F6 lint)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tac_validate.api import lint
6
+ from tac_validate.models import Fix, Issue, LintReport
7
+ from tac_validate.products import PRODUCTS
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["PRODUCTS", "Fix", "Issue", "LintReport", "__version__", "lint"]
tac_validate/api.py ADDED
@@ -0,0 +1,35 @@
1
+ """Public ``lint()`` entrypoint for TAC parse gate + rule pack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tac_validate.models import LintReport
6
+ from tac_validate.products import PRODUCTS
7
+ from tac_validate.rules import check_parse_gate, check_product_rules
8
+
9
+
10
+ def lint(tac_text: str, *, product: str = "METAR") -> LintReport:
11
+ """
12
+ Lint TAC text for ``product`` using the shared rule-pack skeleton.
13
+
14
+ Parameters
15
+ ----------
16
+ tac_text :
17
+ TAC report or fragment.
18
+ product :
19
+ One of AIRMET, METAR, SIGMET, SPECI, TAF, VAA, TCA.
20
+
21
+ Returns
22
+ -------
23
+ LintReport
24
+ ``ok`` is ``False`` when any error-severity issue is present.
25
+ Optional ``fixes`` may suggest repairs (Q9=C).
26
+ """
27
+ issues, fixes = check_parse_gate(tac_text, product)
28
+ if not any(i.severity == "error" for i in issues):
29
+ issues.extend(check_product_rules(tac_text, product))
30
+
31
+ ok = not any(i.severity == "error" for i in issues)
32
+ return LintReport(ok=ok, product=product, issues=issues, fixes=fixes)
33
+
34
+
35
+ __all__ = ["PRODUCTS", "lint"]
tac_validate/cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """Command-line interface for ``tac-validate`` (F12)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ from tac_validate.api import lint
11
+ from tac_validate.codec import json_encoder
12
+ from tac_validate.products import PRODUCTS
13
+
14
+
15
+ def _build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="tac-validate",
18
+ description="Lint TAC text for F6 products (parse-gate + checklist/template gates).",
19
+ )
20
+ parser.add_argument(
21
+ "path",
22
+ type=Path,
23
+ help="Path to a TAC text file",
24
+ )
25
+ parser.add_argument(
26
+ "--product",
27
+ required=True,
28
+ choices=list(PRODUCTS),
29
+ help="F6 product id",
30
+ )
31
+ parser.add_argument(
32
+ "--json",
33
+ action="store_true",
34
+ help="Emit LintReport as JSON on stdout",
35
+ )
36
+ return parser
37
+
38
+
39
+ def main(argv: Sequence[str] | None = None) -> int:
40
+ """
41
+ Run ``tac-validate`` CLI.
42
+
43
+ Parameters
44
+ ----------
45
+ argv :
46
+ Argument vector (defaults to ``sys.argv[1:]``).
47
+
48
+ Returns
49
+ -------
50
+ int
51
+ ``0`` when ``report.ok``; ``1`` on lint errors or I/O failure.
52
+ """
53
+ parser = _build_parser()
54
+ args = parser.parse_args(list(argv) if argv is not None else None)
55
+ path: Path = args.path
56
+ try:
57
+ text = path.read_text(encoding="utf-8")
58
+ except OSError as exc:
59
+ print(f"error: cannot read {path}: {exc}", file=sys.stderr)
60
+ return 1
61
+
62
+ report = lint(text, product=args.product)
63
+ if args.json:
64
+ sys.stdout.write(json_encoder.encode(report).decode("utf-8"))
65
+ sys.stdout.write("\n")
66
+ else:
67
+ status = "ok" if report.ok else "fail"
68
+ print(f"{status} product={report.product} issues={len(report.issues)}")
69
+ for issue in report.issues:
70
+ span = ""
71
+ if issue.start is not None and issue.end is not None:
72
+ span = f" [{issue.start}:{issue.end}]"
73
+ print(f" {issue.severity}:{issue.code}{span} {issue.message}")
74
+
75
+ return 0 if report.ok else 1
76
+
77
+
78
+ if __name__ == "__main__":
79
+ raise SystemExit(main())
tac_validate/codec.py ADDED
@@ -0,0 +1,12 @@
1
+ """Reusable msgspec JSON codec for LintReport (ADR-016)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import msgspec
6
+
7
+ from tac_validate.models import LintReport
8
+
9
+ json_encoder = msgspec.json.Encoder()
10
+ json_decoder = msgspec.json.Decoder(LintReport)
11
+
12
+ __all__ = ["json_decoder", "json_encoder"]
tac_validate/models.py ADDED
@@ -0,0 +1,77 @@
1
+ """msgspec models for TAC lint issues and fixes (ADR-016 / Q9=C)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import msgspec
6
+
7
+
8
+ class Issue(msgspec.Struct, frozen=True):
9
+ """
10
+ Structured TAC lint finding.
11
+
12
+ Parameters
13
+ ----------
14
+ severity :
15
+ ``error``, ``warning``, or ``info``.
16
+ code :
17
+ Machine-readable rule id.
18
+ message :
19
+ Human-readable description.
20
+ location :
21
+ Optional token / field hint (e.g. ``wind``).
22
+ start :
23
+ Optional inclusive character offset into the TAC string.
24
+ end :
25
+ Optional exclusive character offset into the TAC string.
26
+ """
27
+
28
+ severity: str
29
+ code: str
30
+ message: str
31
+ location: str | None = None
32
+ start: int | None = None
33
+ end: int | None = None
34
+
35
+
36
+ class Fix(msgspec.Struct, frozen=True):
37
+ """
38
+ Optional repair suggestion for a lint finding.
39
+
40
+ Parameters
41
+ ----------
42
+ code :
43
+ Fix identifier (e.g. ``add_terminator``).
44
+ message :
45
+ Human-readable description.
46
+ replacement :
47
+ Suggested replacement fragment or full TAC.
48
+ """
49
+
50
+ code: str
51
+ message: str
52
+ replacement: str
53
+
54
+
55
+ class LintReport(msgspec.Struct, frozen=True):
56
+ """
57
+ Result of ``lint()``.
58
+
59
+ Parameters
60
+ ----------
61
+ ok :
62
+ ``True`` when no error-severity issues.
63
+ product :
64
+ Product id used for rule selection.
65
+ issues :
66
+ Structured findings.
67
+ fixes :
68
+ Optional repair suggestions.
69
+ """
70
+
71
+ ok: bool
72
+ product: str
73
+ issues: list[Issue]
74
+ fixes: list[Fix] = msgspec.field(default_factory=list)
75
+
76
+
77
+ __all__ = ["Fix", "Issue", "LintReport"]
@@ -0,0 +1,364 @@
1
+ """Product-specific TAC checklist and template-gate rules (F12 / E10-21).
2
+
3
+ Cite paraphrase tables in ``docs/domain/TAC_VALIDATION.md`` only — no Annex prose.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+
10
+ from tac_validate.models import Issue
11
+
12
+ _ICAO = re.compile(r"\b[A-Z]{4}\b")
13
+ _OBS_TIME = re.compile(r"\b\d{6}Z\b")
14
+ _WIND = re.compile(r"\b(?:(?:VRB|\d{3})\d{2,3}(?:G\d{2,3})?(?:KT|MPS)|CALM)\b")
15
+ _VIS = re.compile(r"\b(?:CAVOK|P?\d{1,2}SM|\d{4})\b")
16
+ _TEMP = re.compile(r"\bM?\d{2}/M?\d{2}\b")
17
+ _QNH = re.compile(r"\b[QA]\d{4}\b")
18
+ _CLOUD_OK = re.compile(r"\b(?:FEW|SCT|BKN|OVC|VV|NSC|NCD|SKC|CLR)\d{0,3}(?:CB|TCU)?\b")
19
+ _CLOUD_BAD = re.compile(r"\b([A-Z]{3}\d{3})\b")
20
+ _TAF_VALIDITY = re.compile(r"\b\d{4}/\d{4}\b")
21
+ _VALID_PERIOD = re.compile(r"\bVALID\s+\d{6}/\d{6}\b", re.IGNORECASE)
22
+ _DTG_LINE = re.compile(r"(?m)^\s*DTG\s*:", re.IGNORECASE)
23
+ _VAAC_LINE = re.compile(r"(?m)^\s*VAAC\s*:", re.IGNORECASE)
24
+ _MAX_WIND_LINE = re.compile(r"(?m)^\s*MAX\s+WIND\s*:", re.IGNORECASE)
25
+
26
+ # Phenomenon family markers (template+gate — not exhaustive Annex vocab).
27
+ _SIGMET_FAMILIES: tuple[tuple[str, re.Pattern[str]], ...] = (
28
+ ("TS", re.compile(r"\b(?:OBSC|EMBD|FRQ|SQL)?\s*TS(?:GR)?\b")),
29
+ ("TURB", re.compile(r"\b(?:SEV|MOD)?\s*TURB\b")),
30
+ ("ICE", re.compile(r"\b(?:SEV|MOD)?\s*ICE\b")),
31
+ ("MTW", re.compile(r"\b(?:SEV|MOD)?\s*MTW\b")),
32
+ ("DS_SS", re.compile(r"\b(?:HVY\s+)?(?:DS|SS)\b")),
33
+ ("VA", re.compile(r"\bVA\b")),
34
+ ("TC", re.compile(r"\bTC\b")),
35
+ ("RDOACT", re.compile(r"\bRDOACT\s+CLD\b")),
36
+ )
37
+ _AIRMET_FAMILIES: tuple[tuple[str, re.Pattern[str]], ...] = (
38
+ ("TS", re.compile(r"\b(?:ISOL|OCNL|FRQ)\s+TS\b")),
39
+ ("ICE", re.compile(r"\b(?:MOD|SEV)?\s*ICE\b")),
40
+ ("TURB", re.compile(r"\b(?:MOD|SEV)?\s*TURB\b")),
41
+ ("MTW", re.compile(r"\b(?:MOD|SEV)?\s*MTW\b")),
42
+ ("MT_OBSC", re.compile(r"\bMT\s+OBSC\b")),
43
+ ("CLD", re.compile(r"\b(?:BKN|OVC)\s+CLD\b")),
44
+ ("CB_TCU", re.compile(r"\b(?:ISOL|OCNL|FRQ)\s+(?:CB|TCU)\b")),
45
+ ("SFC", re.compile(r"\bSFC\s+(?:WIND|VIS)\b")),
46
+ )
47
+
48
+ _METAR_SPECI_SKIP = frozenset({"METAR", "SPECI", "COR", "AUTO", "NIL", "CAVOK", "NOSIG"})
49
+ _TAF_SKIP = frozenset({"TAF", "AMD", "COR", "NIL", "CNL", "CAVOK"})
50
+
51
+
52
+ def _issue(
53
+ code: str,
54
+ message: str,
55
+ *,
56
+ start: int,
57
+ end: int,
58
+ location: str = "body",
59
+ ) -> Issue:
60
+ return Issue(
61
+ severity="error",
62
+ code=code,
63
+ message=message,
64
+ location=location,
65
+ start=start,
66
+ end=end,
67
+ )
68
+
69
+
70
+ def _body_span(tac: str) -> tuple[int, int, str]:
71
+ stripped = tac.strip()
72
+ if not stripped:
73
+ return 0, len(tac), ""
74
+ leading = len(tac) - len(tac.lstrip())
75
+ return leading, leading + len(stripped), stripped
76
+
77
+
78
+ def _first_icao(tokens: list[str], skip: frozenset[str]) -> str | None:
79
+ for tok in tokens:
80
+ if tok in skip:
81
+ continue
82
+ if _ICAO.fullmatch(tok) and tok not in {"KT", "MPS", "SM"}:
83
+ return tok
84
+ return None
85
+
86
+
87
+ def _check_metar_speci(tac: str, product: str) -> list[Issue]:
88
+ start, end, body = _body_span(tac)
89
+ upper = body.upper()
90
+ # Drop trailing '=' for token scans.
91
+ core = upper[:-1] if upper.endswith("=") else upper
92
+ tokens = core.replace("=", " ").split()
93
+ issues: list[Issue] = []
94
+
95
+ cccc = _first_icao(tokens, _METAR_SPECI_SKIP)
96
+ if cccc is None:
97
+ issues.append(
98
+ _issue(
99
+ "MISSING_CCCC",
100
+ f"{product} missing ICAO location (CCCC) — A3-2 #2",
101
+ start=start,
102
+ end=end,
103
+ location="station",
104
+ )
105
+ )
106
+
107
+ if not _OBS_TIME.search(core):
108
+ issues.append(
109
+ _issue(
110
+ "MISSING_OBS_TIME",
111
+ f"{product} missing observation time ddhhmmZ — A3-2 #3",
112
+ start=start,
113
+ end=end,
114
+ location="time",
115
+ )
116
+ )
117
+
118
+ if not _WIND.search(core):
119
+ issues.append(
120
+ _issue(
121
+ "MISSING_WIND",
122
+ f"{product} missing surface wind group — A3-2 #5",
123
+ start=start,
124
+ end=end,
125
+ location="wind",
126
+ )
127
+ )
128
+
129
+ if not _VIS.search(core):
130
+ issues.append(
131
+ _issue(
132
+ "MISSING_VISIBILITY",
133
+ f"{product} missing visibility or CAVOK — A3-2 #6",
134
+ start=start,
135
+ end=end,
136
+ location="visibility",
137
+ )
138
+ )
139
+
140
+ if not _TEMP.search(core):
141
+ issues.append(
142
+ _issue(
143
+ "MISSING_TEMP_DEWPOINT",
144
+ f"{product} missing temperature/dewpoint tt/td — A3-2 #10",
145
+ start=start,
146
+ end=end,
147
+ location="temperature",
148
+ )
149
+ )
150
+
151
+ if not _QNH.search(core):
152
+ issues.append(
153
+ _issue(
154
+ "MISSING_QNH",
155
+ f"{product} missing QNH/altimeter (Qnnnn/Annnn) — A3-2 #11",
156
+ start=start,
157
+ end=end,
158
+ location="pressure",
159
+ )
160
+ )
161
+
162
+ # Flag unknown XXX### cloud-like tokens that are not valid cloud groups.
163
+ for match in _CLOUD_BAD.finditer(core):
164
+ token = match.group(1)
165
+ if _CLOUD_OK.fullmatch(token):
166
+ continue
167
+ # Wind already matched as \d{3}\d{2}KT — not XXX###.
168
+ if token[:3] in {"FEW", "SCT", "BKN", "OVC"}:
169
+ continue
170
+ abs_start = start + match.start(1)
171
+ abs_end = start + match.end(1)
172
+ issues.append(
173
+ _issue(
174
+ "INVALID_CLOUD_TOKEN",
175
+ f"{product} invalid cloud/VV token {token!r} — A3-2 #9",
176
+ start=abs_start,
177
+ end=abs_end,
178
+ location="cloud",
179
+ )
180
+ )
181
+
182
+ return issues
183
+
184
+
185
+ def _check_taf(tac: str) -> list[Issue]:
186
+ start, end, body = _body_span(tac)
187
+ upper = body.upper()
188
+ core = upper[:-1] if upper.endswith("=") else upper
189
+ tokens = core.replace("=", " ").split()
190
+ issues: list[Issue] = []
191
+
192
+ if _first_icao(tokens, _TAF_SKIP) is None:
193
+ issues.append(
194
+ _issue(
195
+ "MISSING_CCCC",
196
+ "TAF missing ICAO location (CCCC) — A5-1 #2",
197
+ start=start,
198
+ end=end,
199
+ location="station",
200
+ )
201
+ )
202
+
203
+ if not _OBS_TIME.search(core):
204
+ issues.append(
205
+ _issue(
206
+ "MISSING_ISSUE_TIME",
207
+ "TAF missing issue time ddhhmmZ — A5-1 #3",
208
+ start=start,
209
+ end=end,
210
+ location="time",
211
+ )
212
+ )
213
+
214
+ if not _TAF_VALIDITY.search(core):
215
+ issues.append(
216
+ _issue(
217
+ "MISSING_VALIDITY",
218
+ "TAF missing validity period ddhh/ddhh — A5-1 #5",
219
+ start=start,
220
+ end=end,
221
+ location="validity",
222
+ )
223
+ )
224
+
225
+ if "CNL" in tokens:
226
+ # CNL must terminate the forecast content (A5-1 #6 paraphrase).
227
+ cnl_idx = tokens.index("CNL")
228
+ trailing = [t for t in tokens[cnl_idx + 1 :] if t not in {"="}]
229
+ if trailing:
230
+ issues.append(
231
+ _issue(
232
+ "INVALID_CNL_SHAPE",
233
+ "TAF CNL must end the message — A5-1 #6",
234
+ start=start,
235
+ end=end,
236
+ location="cnl",
237
+ )
238
+ )
239
+
240
+ return issues
241
+
242
+
243
+ def _count_families(text: str, families: tuple[tuple[str, re.Pattern[str]], ...]) -> list[str]:
244
+ found: list[str] = []
245
+ for name, pattern in families:
246
+ if pattern.search(text):
247
+ found.append(name)
248
+ return found
249
+
250
+
251
+ def _check_sigmet_airmet(tac: str, product: str) -> list[Issue]:
252
+ start, end, body = _body_span(tac)
253
+ upper = body.upper()
254
+ issues: list[Issue] = []
255
+
256
+ if not _VALID_PERIOD.search(upper):
257
+ issues.append(
258
+ _issue(
259
+ "MISSING_VALID",
260
+ f"{product} missing VALID ddhhmm/ddhhmm period — A6 identity",
261
+ start=start,
262
+ end=end,
263
+ location="valid",
264
+ )
265
+ )
266
+
267
+ families = _SIGMET_FAMILIES if product == "SIGMET" else _AIRMET_FAMILIES
268
+ hit = _count_families(upper, families)
269
+ if len(hit) > 1:
270
+ issues.append(
271
+ _issue(
272
+ "MULTIPLE_PHENOMENA",
273
+ f"{product} encodes multiple phenomenon families {hit} — A6 one-phenomenon gate",
274
+ start=start,
275
+ end=end,
276
+ location="phenomenon",
277
+ )
278
+ )
279
+
280
+ return issues
281
+
282
+
283
+ def _check_vaa(tac: str) -> list[Issue]:
284
+ start, end, body = _body_span(tac)
285
+ issues: list[Issue] = []
286
+ if not _DTG_LINE.search(body):
287
+ issues.append(
288
+ _issue(
289
+ "MISSING_DTG",
290
+ "VAA missing DTG: template field — A2-1",
291
+ start=start,
292
+ end=end,
293
+ location="dtg",
294
+ )
295
+ )
296
+ if not _VAAC_LINE.search(body):
297
+ issues.append(
298
+ _issue(
299
+ "MISSING_VAAC",
300
+ "VAA missing VAAC: template field — A2-1",
301
+ start=start,
302
+ end=end,
303
+ location="vaac",
304
+ )
305
+ )
306
+ return issues
307
+
308
+
309
+ def _check_tca(tac: str) -> list[Issue]:
310
+ start, end, body = _body_span(tac)
311
+ issues: list[Issue] = []
312
+ if not _DTG_LINE.search(body):
313
+ issues.append(
314
+ _issue(
315
+ "MISSING_DTG",
316
+ "TCA missing DTG: template field — A2-2",
317
+ start=start,
318
+ end=end,
319
+ location="dtg",
320
+ )
321
+ )
322
+ if not _MAX_WIND_LINE.search(body):
323
+ issues.append(
324
+ _issue(
325
+ "MISSING_MAX_WIND",
326
+ "TCA missing MAX WIND: template field — A2-2",
327
+ start=start,
328
+ end=end,
329
+ location="max_wind",
330
+ )
331
+ )
332
+ return issues
333
+
334
+
335
+ def check_product_rules(tac_text: str, product: str) -> list[Issue]:
336
+ """
337
+ Run product checklist / template-gate rules after parse-gate success.
338
+
339
+ Parameters
340
+ ----------
341
+ tac_text :
342
+ Raw TAC text.
343
+ product :
344
+ F6 product id.
345
+
346
+ Returns
347
+ -------
348
+ list[Issue]
349
+ Error-severity findings with spans when possible.
350
+ """
351
+ if product in {"METAR", "SPECI"}:
352
+ return _check_metar_speci(tac_text, product)
353
+ if product == "TAF":
354
+ return _check_taf(tac_text)
355
+ if product in {"SIGMET", "AIRMET"}:
356
+ return _check_sigmet_airmet(tac_text, product)
357
+ if product == "VAA":
358
+ return _check_vaa(tac_text)
359
+ if product == "TCA":
360
+ return _check_tca(tac_text)
361
+ return []
362
+
363
+
364
+ __all__ = ["check_product_rules"]
@@ -0,0 +1,18 @@
1
+ """Product constants and keyword map for the seven F6 TAC forms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ PRODUCTS: tuple[str, ...] = ("AIRMET", "METAR", "SIGMET", "SPECI", "TAF", "VAA", "TCA")
6
+
7
+ # Leading TAC keywords / bulletin markers used by the parse-gate skeleton.
8
+ PRODUCT_KEYWORDS: dict[str, tuple[str, ...]] = {
9
+ "AIRMET": ("AIRMET",),
10
+ "METAR": ("METAR",),
11
+ "SIGMET": ("SIGMET",),
12
+ "SPECI": ("SPECI",),
13
+ "TAF": ("TAF",),
14
+ "VAA": ("VA ADVISORY", "VAA"),
15
+ "TCA": ("TC ADVISORY", "TCA"),
16
+ }
17
+
18
+ __all__ = ["PRODUCT_KEYWORDS", "PRODUCTS"]
tac_validate/rules.py ADDED
@@ -0,0 +1,120 @@
1
+ """Shared TAC business-rule pack skeleton (seven products)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tac_validate.models import Fix, Issue
6
+ from tac_validate.products import PRODUCT_KEYWORDS, PRODUCTS
7
+
8
+
9
+ def _content_bounds(tac_text: str) -> tuple[int, int, str]:
10
+ """
11
+ Return inclusive start, exclusive end, and stripped body for ``tac_text``.
12
+
13
+ Offsets are relative to the original string so editors can highlight in-place.
14
+ Empty / whitespace-only input spans the entire original string.
15
+ """
16
+ stripped = tac_text.strip()
17
+ if not stripped:
18
+ return 0, len(tac_text), ""
19
+ leading = len(tac_text) - len(tac_text.lstrip())
20
+ return leading, leading + len(stripped), stripped
21
+
22
+
23
+ def check_parse_gate(tac_text: str, product: str) -> tuple[list[Issue], list[Fix]]:
24
+ """
25
+ Run parse-gate checks shared across products.
26
+
27
+ Parameters
28
+ ----------
29
+ tac_text :
30
+ Raw TAC text.
31
+ product :
32
+ F6 product id.
33
+
34
+ Returns
35
+ -------
36
+ issues, fixes
37
+ Structured findings and optional repairs. Issues include ``start``/``end``
38
+ character offsets when the rule can locate a span in ``tac_text``.
39
+ """
40
+ issues: list[Issue] = []
41
+ fixes: list[Fix] = []
42
+
43
+ if product not in PRODUCTS:
44
+ issues.append(
45
+ Issue(
46
+ severity="error",
47
+ code="UNKNOWN_PRODUCT",
48
+ message=f"Unknown product {product!r}; expected one of {list(PRODUCTS)}",
49
+ )
50
+ )
51
+ return issues, fixes
52
+
53
+ start, end, stripped = _content_bounds(tac_text)
54
+ if not stripped:
55
+ issues.append(
56
+ Issue(
57
+ severity="error",
58
+ code="EMPTY_TAC",
59
+ message="TAC text is empty",
60
+ location="body",
61
+ start=start,
62
+ end=end,
63
+ )
64
+ )
65
+ return issues, fixes
66
+
67
+ keywords = PRODUCT_KEYWORDS[product]
68
+ upper = stripped.upper()
69
+ if not any(keyword in upper for keyword in keywords):
70
+ issues.append(
71
+ Issue(
72
+ severity="error",
73
+ code="MISSING_PRODUCT_KEYWORD",
74
+ message=f"{product} TAC must contain one of {list(keywords)}",
75
+ location="header",
76
+ start=start,
77
+ end=end,
78
+ )
79
+ )
80
+
81
+ # Common repairable: missing report terminator '=' on METAR/SPECI/TAF
82
+ if product in {"METAR", "SPECI", "TAF"} and not stripped.rstrip().endswith("="):
83
+ if not any(i.code == "MISSING_PRODUCT_KEYWORD" for i in issues):
84
+ core = stripped.rstrip()
85
+ # Highlight the final character of the report (terminator should follow).
86
+ term_end = start + len(core)
87
+ term_start = term_end - 1 if core else start
88
+ issues.append(
89
+ Issue(
90
+ severity="info",
91
+ code="MISSING_TERMINATOR",
92
+ message="Reports in bulletins end with '=' — add it before publishing",
93
+ location="terminator",
94
+ start=term_start,
95
+ end=term_end,
96
+ )
97
+ )
98
+ fixes.append(
99
+ Fix(
100
+ code="add_terminator",
101
+ message="Add '='",
102
+ replacement=stripped.rstrip() + "=",
103
+ )
104
+ )
105
+
106
+ return issues, fixes
107
+
108
+
109
+ def check_product_rules(tac_text: str, product: str) -> list[Issue]:
110
+ """
111
+ Product-specific checklist / template-gate rules (F12 / E10-21).
112
+
113
+ Delegates to ``product_rules`` after parse-gate success.
114
+ """
115
+ from tac_validate.product_rules import check_product_rules as _impl
116
+
117
+ return _impl(tac_text, product)
118
+
119
+
120
+ __all__ = ["check_parse_gate", "check_product_rules"]
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: tac-validate
3
+ Version: 0.1.0
4
+ Summary: TAC parse gate and business-rule pack (F6 lint)
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: msgspec>=0.19
8
+ Description-Content-Type: text/markdown
9
+
10
+ # tac-validate
11
+
12
+ TAC parse gate and shared business-rule pack for F6 products. MIT licensed.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install tac-validate==0.1.0
18
+ # or from the monorepo workspace:
19
+ uv sync --package tac-validate
20
+ ```
21
+
22
+ ## Library
23
+
24
+ ```python
25
+ from tac_validate import lint
26
+
27
+ report = lint("METAR KJFK ...=", product="METAR")
28
+ if not report.ok:
29
+ for issue in report.issues:
30
+ print(issue.code, issue.message)
31
+ for fix in report.fixes:
32
+ print(fix.code, fix.replacement)
33
+ ```
34
+
35
+ ## CLI
36
+
37
+ ```bash
38
+ tac-validate --product METAR path/to/report.tac
39
+ tac-validate --product METAR --json path/to/report.tac
40
+ ```
41
+
42
+ Exit code `0` when lint `ok`; `1` when error-severity issues are present (or the file cannot be read).
43
+
44
+ Rule depth (E10-21): METAR/SPECI/TAF full checklist; SIGMET/AIRMET/VAA/TCA template+gates.
45
+ Citations live in `docs/domain/TAC_VALIDATION.md` — no Annex prose in the wheel.
46
+
47
+ See ADR-015 / ADR-016. No FastAPI/Supabase imports; HTTP maps msgspec → pydantic.
@@ -0,0 +1,12 @@
1
+ tac_validate/__init__.py,sha256=HytxKloGsoa2Vpz2DqJYpks7OYoYv1lwuPwMPrC_ZH8,324
2
+ tac_validate/api.py,sha256=7rxhXBCTa31zLC5rm7ENNrBnpYAWwVlF_sEErAzQoBQ,1068
3
+ tac_validate/cli.py,sha256=r3V-TInkPHCGrEsdlvHraMgG3ZXxctUzeSpZ9l9iHmg,2150
4
+ tac_validate/codec.py,sha256=HYBQ0ToQk7aI5qUZu0DpqL79kdrT9Jt72gLPoEQl-Qc,287
5
+ tac_validate/models.py,sha256=3KFdyBi3KZVlnwLwXYWEJC6j_2MZXWNNx8ul5wRocKY,1617
6
+ tac_validate/product_rules.py,sha256=bkiK8ZvsU2F7OeiOmvvezygDFK4YwfYC1zaVhRH-fh0,10805
7
+ tac_validate/products.py,sha256=Yb2U3AdqN76jlSFyHawdDy5Waj3eqFUmZmeF4PtQvik,559
8
+ tac_validate/rules.py,sha256=lG9vHE8ABu6L9rPgbRFahoIWka8LblDvaptq8CTY7Ac,3785
9
+ tac_validate-0.1.0.dist-info/METADATA,sha256=uZC5c4Vd8DZcpq6Y3E4KMrYdO4ynCpGJSwnO9LDQ8Qk,1191
10
+ tac_validate-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
11
+ tac_validate-0.1.0.dist-info/entry_points.txt,sha256=xlQaW5C352r0GtFGdNAdUyLYNKwgpA6FcExgUjn9EME,55
12
+ tac_validate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tac-validate = tac_validate.cli:main