Skip to content

Commit 1914ea8

Browse files
authored
Merge branch 'main' into issue/4-ci-node24-actions
2 parents e819951 + f4bf660 commit 1914ea8

11 files changed

Lines changed: 149 additions & 66 deletions

File tree

docs/indexes.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,13 @@ practice that's a few KB of paradox script and ~1 ms.
130130

131131
1. Inherit `GenericTxtIndex` in `src/md_mcp/indexes/<name>.py`.
132132
2. Set the class attributes: `cache_version`, `cache_name`, `subdir`,
133-
`pattern`, `content_prefilter`, `primary_key`.
133+
`pattern`, `primary_key`.
134134
3. Define a **module-level** parser fn (so `ProcessPoolExecutor` can pickle it)
135135
with signature `(abs_path: str, relpath: str) -> Optional[List[dict]]`.
136-
Return `None` for "can't parse"; `[]` for "no records found".
136+
Return `None` for "can't parse"; `[]` for "no records found". Have it read
137+
the file and do its own cheap substring check up front (see `event.py`,
138+
`idea.py`, `gfx.py`) so files that obviously don't contain the record kind
139+
skip the full parse.
137140
4. Set `parser_fn = _your_parser_fn` on the class.
138141
5. Add the class to `src/md_mcp/indexes/__init__.py`.
139142
6. Wire into `server.py`: instantiate once, pass to resolver/analysis tools.

src/md_mcp/indexes/base.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,6 @@ class GenericTxtIndex:
321321
* `cache_name: str` — file basename under `<cache_dir>/v<ver>/`
322322
* `subdir: str` — path under each root, e.g. `events`
323323
* `pattern: str` — glob like `*.txt`
324-
* `content_prefilter` — cheap substring test before parsing
325324
* `parser_fn` — *module-level* function
326325
`(abs_path: str, relpath: str) -> Optional[list[dict]]`
327326
(must be picklable for ProcessPoolExecutor)
@@ -337,7 +336,6 @@ class GenericTxtIndex:
337336
cache_name: str = ""
338337
subdir: str = ""
339338
pattern: str = "*.txt"
340-
content_prefilter: tuple = ()
341339
primary_key: str = "id"
342340

343341
def __init__(

src/md_mcp/indexes/event.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,21 @@
66
from typing import Optional
77

88
from ..paradox import parse_string
9-
from ..paradox.schema import extract_event_records
9+
from ..paradox.schema import EVENT_KINDS, extract_event_records
1010
from ..util.encoding import read_text
1111
from .base import GenericTxtIndex
1212

1313
logger = logging.getLogger(__name__)
1414

1515

16-
_EVENT_TOKENS = (
17-
"country_event",
18-
"news_event",
19-
"state_event",
20-
"unit_leader_event",
21-
"operative_leader_event",
22-
)
23-
24-
2516
def _parse_event_file(abs_path: str, relpath: str) -> Optional[list[dict]]:
2617
"""Top-level so ProcessPoolExecutor can pickle and dispatch this."""
2718
try:
2819
text = read_text(abs_path)
2920
except OSError as e:
3021
logger.warning("event index: cannot read %s: %s", abs_path, e)
3122
return None
32-
if not any(tok in text for tok in _EVENT_TOKENS):
23+
if not any(tok in text for tok in EVENT_KINDS):
3324
return []
3425
try:
3526
root = parse_string(text, error_prefix=f"In file {relpath}:\n")

src/md_mcp/indexes/gfx.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,16 @@
2020
from typing import Optional
2121

2222
from ..paradox import parse_string
23-
from ..paradox.schema import extract_sprite_records
23+
from ..paradox.schema import SPRITE_KINDS, extract_sprite_records
2424
from ..util.encoding import read_text
2525
from .base import GenericTxtIndex
2626

2727
logger = logging.getLogger(__name__)
2828

29-
_SPRITE_KINDS = (
30-
"spriteType",
31-
"corneredTileSpriteType",
32-
"frameAnimatedSpriteType",
33-
"maskedShieldType",
34-
"progressbartype",
35-
"barChartType",
36-
"PieChartType",
37-
"LineChartType",
38-
"scrollingSprite",
39-
)
40-
4129
# Match `<kind> = {`. Case-sensitive because HOI4 itself is case-sensitive on identifiers
4230
# (per general-rules.md). Field-name regexes stay case-insensitive — `name` and
4331
# `texturefile` are *property* keys inside a sprite block and the engine is lenient there.
44-
_SPRITE_OPEN_RE = re.compile(r"\b(" + "|".join(_SPRITE_KINDS) + r")\s*=\s*\{")
32+
_SPRITE_OPEN_RE = re.compile(r"\b(" + "|".join(SPRITE_KINDS) + r")\s*=\s*\{")
4533
_NAME_RE = re.compile(r'\bname\s*=\s*"([^"\\]*(?:\\.[^"\\]*)*)"', re.IGNORECASE)
4634
_NAME_BARE_RE = re.compile(r"\bname\s*=\s*([A-Za-z_][\w.]*)", re.IGNORECASE)
4735
_TEXTUREFILE_RE = re.compile(r'\btexturefile\s*=\s*"([^"]+)"', re.IGNORECASE)

src/md_mcp/paradox/schema.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
"""Schema projections — extract typed information from a parsed AST.
22
3-
The TS `schema.ts` exposes a full `convertNodeToJson(node, schemaDef)` system. For
4-
Milestone 1, we only need:
3+
The TS `schema.ts` exposes a full `convertNodeToJson(node, schemaDef)` system. This
4+
module covers the subset the MCP server needs:
55
66
* `to_json(node)` — convert any Node to a JSON-serialisable dict (used by `parse_file`
77
and `parse_string` MCP tools)
88
* `extract_focus_ids(root)` — port of `extractFocusIds` from `previewdef/focustree/schema.ts`
9-
10-
Additional extractors (events, decisions, ideas, sprites) land in Milestone 2.
9+
* Extractors for events, decisions, ideas, and sprites, plus the `EVENT_KINDS` /
10+
`SPRITE_KINDS` container-kind lists that `indexes/` reuses to stay in sync.
1111
"""
1212

1313
from __future__ import annotations
@@ -214,15 +214,21 @@ def _get_id(node: Node) -> str | None:
214214
# ---------------------------------------------------------------------------
215215

216216

217-
_EVENT_KINDS = frozenset(
218-
{"country_event", "news_event", "state_event", "unit_leader_event", "operative_leader_event"}
217+
# Canonical list of event container kinds — indexes/event.py reuses this for its
218+
# text prefilter so a new kind only needs to be added here.
219+
EVENT_KINDS = (
220+
"country_event",
221+
"news_event",
222+
"state_event",
223+
"unit_leader_event",
224+
"operative_leader_event",
219225
)
220226

221227

222228
def _iter_event_definitions(root: Node) -> Iterator[tuple[Node, str]]:
223229
"""Yield `(node, id_str)` for every node satisfying the event hierarchy."""
224230
for top in root.children():
225-
if top.name in _EVENT_KINDS:
231+
if top.name in EVENT_KINDS:
226232
id_str = _get_id(top)
227233
if id_str:
228234
yield top, id_str
@@ -477,18 +483,18 @@ def _looks_like_slot_wrapper(node: Node) -> bool:
477483
# ---------------------------------------------------------------------------
478484

479485

480-
_SPRITE_KINDS = frozenset(
481-
{
482-
"spriteType",
483-
"corneredTileSpriteType",
484-
"frameAnimatedSpriteType",
485-
"maskedShieldType",
486-
"progressbartype",
487-
"barChartType",
488-
"PieChartType",
489-
"LineChartType",
490-
"scrollingSprite",
491-
}
486+
# Canonical list of sprite container kinds — indexes/gfx.py reuses this to build
487+
# its regex scanner, so order must stay deterministic (a plain tuple, not a set).
488+
SPRITE_KINDS = (
489+
"spriteType",
490+
"corneredTileSpriteType",
491+
"frameAnimatedSpriteType",
492+
"maskedShieldType",
493+
"progressbartype",
494+
"barChartType",
495+
"PieChartType",
496+
"LineChartType",
497+
"scrollingSprite",
492498
)
493499

494500

@@ -503,7 +509,7 @@ def _iter_sprite_definitions(root: Node) -> Iterator[tuple[Node, str, str]]:
503509
if not (top.name and top.name.lower().startswith("spritetypes")):
504510
continue
505511
for sprite in top.children():
506-
if sprite.name not in _SPRITE_KINDS:
512+
if sprite.name not in SPRITE_KINDS:
507513
continue
508514
name_node = sprite.get("name")
509515
if name_node is None:

src/md_mcp/tools/linting_tools.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from typing import Callable, Optional, Sequence
3030

3131
from ..util.response import BUDGET_BYTES, enforce_budget
32-
from ..validators import SLOW_VALIDATORS, ValidatorRunner
32+
from ..validators import SEVERITY_RANK, SLOW_VALIDATORS, ValidatorRunner
3333
from .lint_validators import run_validators_for_lint, select_validators
3434

3535
_LINT_LINE_RE = re.compile(r"^(?P<file>[^:]+):(?P<line>\d+):\s*(?P<msg>.+)$")
@@ -44,8 +44,6 @@
4444
# ANSI escape stripper for scripts that emit colour codes.
4545
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
4646

47-
_SEVERITY_RANK = {"info": 0, "warning": 1, "error": 2}
48-
4947
# Leave room for status fields and stderr within the response budget.
5048
_MAX_REVIEW_REPORT_BYTES = max(1, BUDGET_BYTES - 12_000)
5149

@@ -579,8 +577,8 @@ def _maybe(files_list: Optional[list[str]], runner: Callable[[], dict]) -> dict:
579577
overall[sev] = overall.get(sev, 0) + 1
580578
all_issues.extend(v_issues)
581579

582-
floor = _SEVERITY_RANK.get(severity_min, 0)
583-
filtered = [i for i in all_issues if _SEVERITY_RANK.get(i.get("severity", "info"), 0) >= floor]
580+
floor = SEVERITY_RANK.get(severity_min, 0)
581+
filtered = [i for i in all_issues if SEVERITY_RANK.get(i.get("severity", "info"), 0) >= floor]
584582
truncated = len(filtered) > limit if limit >= 0 else False
585583
issues_capped = filtered[:limit] if limit >= 0 else filtered
586584

src/md_mcp/tools/validation_tools.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010

1111
from ..config import Settings
1212
from ..util.response import coerce_int, enforce_budget, paginate
13-
from ..validators import SLOW_VALIDATORS, ValidatorRunner, available_validators
14-
15-
_SEVERITY_RANK = {"info": 0, "warning": 1, "error": 2}
13+
from ..validators import SEVERITY_RANK, SLOW_VALIDATORS, ValidatorRunner, available_validators
1614

1715

1816
def validate_list_tool(
@@ -60,8 +58,8 @@ def _filter_and_cap(
6058
limit: int,
6159
) -> tuple[list[dict], bool, int]:
6260
"""Apply severity floor + cap. Returns (kept, truncated, total_after_filter)."""
63-
floor = _SEVERITY_RANK.get(severity_min, 0)
64-
filtered = [i for i in issues if _SEVERITY_RANK.get(i.get("severity", "info"), 0) >= floor]
61+
floor = SEVERITY_RANK.get(severity_min, 0)
62+
filtered = [i for i in issues if SEVERITY_RANK.get(i.get("severity", "info"), 0) >= floor]
6563
total = len(filtered)
6664
if limit >= 0 and total > limit:
6765
return filtered[:limit], True, total

src/md_mcp/validators/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,13 @@
44
# paths ("*" in lint, validator=None in validate); reachable by explicit name.
55
SLOW_VALIDATORS: frozenset = frozenset({"unused_scripted", "unused_textures"})
66

7-
__all__ = ["SLOW_VALIDATORS", "ValidatorInfo", "ValidatorRunner", "available_validators"]
7+
# Shared severity ordering for `severity_min` floor filters in lint/validate tools.
8+
SEVERITY_RANK = {"info": 0, "warning": 1, "error": 2}
9+
10+
__all__ = [
11+
"SEVERITY_RANK",
12+
"SLOW_VALIDATORS",
13+
"ValidatorInfo",
14+
"ValidatorRunner",
15+
"available_validators",
16+
]

tests/test_indexes_m2.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
from __future__ import annotations
44

55
from md_mcp.indexes import DecisionIndex, EventIndex, GfxIndex, IdeaIndex
6+
from md_mcp.indexes.event import _parse_event_file
7+
from md_mcp.indexes.gfx import _parse_gfx_file
8+
from md_mcp.indexes.idea import _parse_idea_file
69

710

811
def test_gfx_index_builds(fake_mod_root, cache_dir):
@@ -71,3 +74,27 @@ def test_idea_index_categories(fake_mod_root, cache_dir):
7174
assert acme is not None and acme["category"] == "tank_manufacturer"
7275
# The slot wrapper itself (designer / law) must NOT be indexed as an idea.
7376
assert "designer" not in i.list_keys()
77+
78+
79+
# The unclosed brace makes a real parse fail (return None), so these pass only
80+
# if the prefilter short-circuits before parsing.
81+
_TOKEN_FREE_MALFORMED = "some_unrelated_block = { foo = bar"
82+
83+
84+
def test_event_parser_skips_files_without_event_tokens(tmp_path):
85+
"""No `country_event`/`news_event`/etc. token means the file is skipped pre-parse."""
86+
f = tmp_path / "no_events.txt"
87+
f.write_text(_TOKEN_FREE_MALFORMED, encoding="utf-8")
88+
assert _parse_event_file(str(f), "no_events.txt") == []
89+
90+
91+
def test_idea_parser_skips_files_without_ideas_token(tmp_path):
92+
f = tmp_path / "no_ideas.txt"
93+
f.write_text(_TOKEN_FREE_MALFORMED, encoding="utf-8")
94+
assert _parse_idea_file(str(f), "no_ideas.txt") == []
95+
96+
97+
def test_gfx_parser_skips_files_without_sprite_token(tmp_path):
98+
f = tmp_path / "no_sprites.gfx"
99+
f.write_text(_TOKEN_FREE_MALFORMED, encoding="utf-8")
100+
assert _parse_gfx_file(str(f), "no_sprites.gfx") == []

tests/test_ref_audit.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ def test_sites_carry_file_line_and_referrer(audit_mod):
116116
entry = next(e for e in out["unresolved"] if e["ref"] == "TST_missing_focus")
117117
site = entry["sites"][0]
118118
assert site["file"] == "common/national_focus/TST_audit.txt"
119-
assert isinstance(site["line"], int) and site["line"] > 1
119+
# Hand-counted from _FOCUS_FILE: the `prerequisite = { ... }` line.
120+
assert site["line"] == 20
120121
assert site["via"] == "prerequisite"
121122
assert site["referrer"] == "TST_audit_child"
122123

0 commit comments

Comments
 (0)