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.
- tac_validate/__init__.py +11 -0
- tac_validate/api.py +35 -0
- tac_validate/cli.py +79 -0
- tac_validate/codec.py +12 -0
- tac_validate/models.py +77 -0
- tac_validate/product_rules.py +364 -0
- tac_validate/products.py +18 -0
- tac_validate/rules.py +120 -0
- tac_validate-0.1.0.dist-info/METADATA +47 -0
- tac_validate-0.1.0.dist-info/RECORD +12 -0
- tac_validate-0.1.0.dist-info/WHEEL +4 -0
- tac_validate-0.1.0.dist-info/entry_points.txt +2 -0
tac_validate/__init__.py
ADDED
|
@@ -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"]
|
tac_validate/products.py
ADDED
|
@@ -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,,
|