dynamic-systems-testing-framework 1.0.1__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.
Files changed (185) hide show
  1. dstf/__init__.py +41 -0
  2. dstf/__main__.py +10 -0
  3. dstf/authoring/__init__.py +13 -0
  4. dstf/authoring/builtin/__init__.py +32 -0
  5. dstf/authoring/builtin/accept.py +205 -0
  6. dstf/authoring/builtin/edit_criteria.py +479 -0
  7. dstf/authoring/builtin/edit_family.py +228 -0
  8. dstf/authoring/builtin/edit_on_plot.py +1354 -0
  9. dstf/authoring/builtin/edit_tree.py +1399 -0
  10. dstf/authoring/builtin/enqueue.py +144 -0
  11. dstf/authoring/capabilities.py +463 -0
  12. dstf/authoring/errors.py +49 -0
  13. dstf/authoring/family_rows.py +526 -0
  14. dstf/authoring/forms.py +631 -0
  15. dstf/authoring/layout.py +104 -0
  16. dstf/authoring/live_rows.py +337 -0
  17. dstf/authoring/live_runs.py +385 -0
  18. dstf/authoring/needle.py +127 -0
  19. dstf/authoring/picker_rows.py +92 -0
  20. dstf/authoring/queue_rows.py +354 -0
  21. dstf/authoring/session.py +3016 -0
  22. dstf/authoring/shell/__init__.py +9 -0
  23. dstf/authoring/shell/actions.py +702 -0
  24. dstf/authoring/shell/app.py +53 -0
  25. dstf/authoring/shell/drawer_controls.py +190 -0
  26. dstf/authoring/shell/family_fold.py +468 -0
  27. dstf/authoring/shell/figure_hook.py +249 -0
  28. dstf/authoring/shell/grips.js +1000 -0
  29. dstf/authoring/shell/header.py +783 -0
  30. dstf/authoring/shell/live_view.py +93 -0
  31. dstf/authoring/shell/picker.py +267 -0
  32. dstf/authoring/shell/placing.py +122 -0
  33. dstf/authoring/shell/plot_card.py +1171 -0
  34. dstf/authoring/shell/queue_panel.py +433 -0
  35. dstf/authoring/shell/rail.py +593 -0
  36. dstf/authoring/shell/shell.py +528 -0
  37. dstf/authoring/shell/stats_card.py +501 -0
  38. dstf/authoring/shell/strip.py +204 -0
  39. dstf/authoring/shell/style.py +721 -0
  40. dstf/authoring/shell/suite.py +277 -0
  41. dstf/authoring/shell/suite_grid.js +414 -0
  42. dstf/authoring/shell/suite_grid.py +373 -0
  43. dstf/authoring/shell/tab_theme.py +110 -0
  44. dstf/authoring/shell/tabstate.py +146 -0
  45. dstf/authoring/shell/test_list.py +179 -0
  46. dstf/authoring/shell/test_page.py +1404 -0
  47. dstf/authoring/shell/tree_card.py +1171 -0
  48. dstf/authoring/shell/variables_drawer.py +589 -0
  49. dstf/authoring/shell/widgets.py +188 -0
  50. dstf/authoring/situation.py +287 -0
  51. dstf/authoring/spec_document.py +215 -0
  52. dstf/authoring/spec_preview.py +83 -0
  53. dstf/authoring/suite_rows.py +99 -0
  54. dstf/authoring/tree_move.py +516 -0
  55. dstf/authoring/tree_pointer.py +33 -0
  56. dstf/authoring/tree_text.py +61 -0
  57. dstf/authoring/typed_number.js +64 -0
  58. dstf/authoring/typed_number.py +76 -0
  59. dstf/authoring/unscorable.py +141 -0
  60. dstf/authoring/variable_rows.py +216 -0
  61. dstf/backends/__init__.py +283 -0
  62. dstf/backends/base.py +1117 -0
  63. dstf/backends/common/__init__.py +1 -0
  64. dstf/backends/common/json_driver.py +484 -0
  65. dstf/backends/common/live_mat.py +369 -0
  66. dstf/backends/common/mat_reader.py +632 -0
  67. dstf/backends/common/proc_kill.py +238 -0
  68. dstf/backends/common/proc_output.py +20 -0
  69. dstf/backends/common/result_files.py +109 -0
  70. dstf/backends/common/result_variables.py +329 -0
  71. dstf/backends/common/statistics.py +39 -0
  72. dstf/backends/cross_backend.py +193 -0
  73. dstf/backends/dymola/__init__.py +5 -0
  74. dstf/backends/dymola/backend.py +776 -0
  75. dstf/backends/dymola/interface_loader.py +241 -0
  76. dstf/backends/dymola/log_parser.py +396 -0
  77. dstf/backends/dymola/persistent_backend.py +949 -0
  78. dstf/backends/fmpy/__init__.py +11 -0
  79. dstf/backends/fmpy/backend.py +443 -0
  80. dstf/backends/julia/__init__.py +27 -0
  81. dstf/backends/julia/backend.py +156 -0
  82. dstf/backends/julia/persistent_backend.py +541 -0
  83. dstf/backends/julia/run_persistent.jl +167 -0
  84. dstf/backends/julia/run_test.jl +146 -0
  85. dstf/backends/openmodelica/__init__.py +21 -0
  86. dstf/backends/openmodelica/backend.py +368 -0
  87. dstf/backends/openmodelica/init_xml.py +76 -0
  88. dstf/backends/openmodelica/log_parser.py +119 -0
  89. dstf/backends/openmodelica/mos_generator.py +228 -0
  90. dstf/backends/openmodelica/persistent_backend.py +1045 -0
  91. dstf/backends/openmodelica/session_loader.py +63 -0
  92. dstf/backends/persistent.py +1811 -0
  93. dstf/backends/pool.py +602 -0
  94. dstf/backends/progress.py +395 -0
  95. dstf/backends/python/__init__.py +11 -0
  96. dstf/backends/python/backend.py +141 -0
  97. dstf/backends/python/run_test.py +141 -0
  98. dstf/backends/run_metadata.py +103 -0
  99. dstf/backends/types.py +310 -0
  100. dstf/cli/__init__.py +12 -0
  101. dstf/cli/author_cmd.py +61 -0
  102. dstf/cli/baseline_cmds.py +341 -0
  103. dstf/cli/common.py +231 -0
  104. dstf/cli/diag_cmds.py +350 -0
  105. dstf/cli/main.py +561 -0
  106. dstf/cli/review.py +398 -0
  107. dstf/cli/run_cmds.py +589 -0
  108. dstf/cli/spec_cmds.py +123 -0
  109. dstf/comparison/__init__.py +13 -0
  110. dstf/comparison/algorithms.py +2127 -0
  111. dstf/comparison/builtins.py +291 -0
  112. dstf/comparison/comparator.py +1189 -0
  113. dstf/comparison/geometry.py +164 -0
  114. dstf/comparison/handles.py +1194 -0
  115. dstf/comparison/metric_tree.py +332 -0
  116. dstf/comparison/mode_schema.py +176 -0
  117. dstf/comparison/modes.py +788 -0
  118. dstf/comparison/registry.py +230 -0
  119. dstf/comparison/seeds.py +281 -0
  120. dstf/comparison/sides.py +158 -0
  121. dstf/comparison/snap.py +62 -0
  122. dstf/comparison/strict_keys.py +246 -0
  123. dstf/comparison/summaries.py +354 -0
  124. dstf/comparison/tree_eval.py +868 -0
  125. dstf/comparison/tree_spec.py +678 -0
  126. dstf/comparison/types.py +194 -0
  127. dstf/comparison/validator.py +232 -0
  128. dstf/config.py +815 -0
  129. dstf/discovery/__init__.py +0 -0
  130. dstf/discovery/json_recognizer.py +702 -0
  131. dstf/discovery/library_state.py +248 -0
  132. dstf/discovery/mo_parser.py +794 -0
  133. dstf/discovery/patch_apply.py +308 -0
  134. dstf/discovery/patch_envelope.py +63 -0
  135. dstf/discovery/plots.py +318 -0
  136. dstf/discovery/recognizer.py +103 -0
  137. dstf/discovery/scan_cache.py +154 -0
  138. dstf/discovery/spec_parser.py +538 -0
  139. dstf/discovery/spec_shape.py +122 -0
  140. dstf/discovery/spec_update.py +493 -0
  141. dstf/discovery/test_registry.py +488 -0
  142. dstf/discovery/unit_tests.py +95 -0
  143. dstf/figures/__init__.py +14 -0
  144. dstf/figures/assemble.py +48 -0
  145. dstf/figures/decimate.py +150 -0
  146. dstf/figures/families.py +289 -0
  147. dstf/figures/palette.py +170 -0
  148. dstf/figures/plotly.py +1413 -0
  149. dstf/figures/view.py +233 -0
  150. dstf/pipeline.py +435 -0
  151. dstf/plugins.py +104 -0
  152. dstf/reporting/__init__.py +0 -0
  153. dstf/reporting/console_report.py +187 -0
  154. dstf/reporting/context_builder.py +979 -0
  155. dstf/reporting/dashboard_render.py +534 -0
  156. dstf/reporting/junit_report.py +192 -0
  157. dstf/reporting/overlay_loader.py +708 -0
  158. dstf/reporting/report_suite.py +1059 -0
  159. dstf/reporting/rows.py +458 -0
  160. dstf/reporting/schema_export.py +413 -0
  161. dstf/reporting/stats_rows.py +321 -0
  162. dstf/reporting/status.py +388 -0
  163. dstf/reporting/templates/axes.js +61 -0
  164. dstf/reporting/templates/dashboard.html +284 -0
  165. dstf/reporting/templates/dashboard.js +604 -0
  166. dstf/reporting/templates/filters.js +100 -0
  167. dstf/reporting/templates/interactive.html +420 -0
  168. dstf/reporting/templates/report.js +116 -0
  169. dstf/reporting/templates/vendor/plotly.min.js +8 -0
  170. dstf/reporting/theme.py +324 -0
  171. dstf/reporting/tree_rows.py +73 -0
  172. dstf/reporting/verdicts.py +124 -0
  173. dstf/storage/__init__.py +0 -0
  174. dstf/storage/accept.py +216 -0
  175. dstf/storage/baseline.py +43 -0
  176. dstf/storage/batch_manifest.py +204 -0
  177. dstf/storage/ref_index.py +295 -0
  178. dstf/storage/reference_store.py +661 -0
  179. dstf/storage/roles.py +576 -0
  180. dstf/storage/run_stats.py +85 -0
  181. dynamic_systems_testing_framework-1.0.1.dist-info/METADATA +266 -0
  182. dynamic_systems_testing_framework-1.0.1.dist-info/RECORD +185 -0
  183. dynamic_systems_testing_framework-1.0.1.dist-info/WHEEL +4 -0
  184. dynamic_systems_testing_framework-1.0.1.dist-info/entry_points.txt +2 -0
  185. dynamic_systems_testing_framework-1.0.1.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,702 @@
1
+ """JSON-driven Recognizer (PTA.2).
2
+
3
+ Lets users declare a custom test-recognition convention in `testing.json`
4
+ without writing Python. Schema:
5
+
6
+ {
7
+ "name": "<unique recognizer name>",
8
+ "applies_to": ["modelica"], # Config.source_type values
9
+ "paths_include": ["Examples/*"], # optional fnmatch globs on the
10
+ "paths_exclude": ["**/Internal/*"], # source-relative file path
11
+ "match": {
12
+ "type": "component-instantiation" | "extends" | "class-name-glob"
13
+ | "all-of" | "any-of",
14
+ ... # match-type-specific keys
15
+ },
16
+ "fields": {
17
+ "<TestModel-field-name>": {
18
+ "from": "parameter" | "constant" | "annotation"
19
+ | "experiment-annotation",
20
+ ... # source-specific keys
21
+ },
22
+ ...
23
+ }
24
+ }
25
+
26
+ Modelica match types (registered via `@_register_match`):
27
+ - `component-instantiation` (`component_name`): match any Modelica class
28
+ that instantiates a component of the given class. Accepts the fully
29
+ qualified name and any tail-suffix (Modelica's import rules).
30
+ - `extends` (`class_pattern`): match any class with `extends X` where X
31
+ matches the given fnmatch glob (e.g. `*Icons.Example`).
32
+ - `class-name-glob` (`class_pattern`): match on the class's own fully
33
+ qualified name (`within` + class name).
34
+ - `all-of` / `any-of` (`matchers`): compose child match specs. `all-of`
35
+ requires every child and aggregates their contexts; `any-of` takes the
36
+ first child that matches.
37
+
38
+ Field sources (registered via `@_register_field`):
39
+ - `parameter` (`name`, optional `shape`): extract from the matched
40
+ component's parameter list. Only valid where the spec contains a
41
+ `component-instantiation` leaf. `shape`: `scalar` (default), `array`,
42
+ `array_floats`.
43
+ - `constant` (`value`): use the literal value verbatim.
44
+ - `annotation` (`annotation`, `name`, optional `shape`): extract a key
45
+ from an arbitrary annotation block (e.g. `__MyVendor_Test`).
46
+ - `experiment-annotation` (`name`): extract from the standard Modelica
47
+ `experiment(...)` annotation. Case-insensitive first letter (handles
48
+ `StopTime` vs. `stopTime`).
49
+
50
+ `_MATCH_TYPE_ALLOWED_FIELD_SOURCES` gates which sources a given match type
51
+ admits, validated at parse time.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import fnmatch
57
+ import logging
58
+ import re
59
+ from collections.abc import Callable
60
+ from dataclasses import dataclass
61
+ from pathlib import Path
62
+ from typing import Any
63
+
64
+ from .mo_parser import (
65
+ _extract_balanced_braces,
66
+ _extract_model_name,
67
+ _extract_within,
68
+ _find_experiment_span,
69
+ _parse_float_list,
70
+ _parse_x_expressions,
71
+ _strip_modelica_literals,
72
+ )
73
+ from .recognizer import Recognizer, RecognizerResult
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+
78
+ class RecognizerSpecError(ValueError):
79
+ """Raised when a JSON recognizer spec is malformed."""
80
+
81
+
82
+ # Fields a JSON spec can write into a RecognizerResult. Keep in sync with
83
+ # RecognizerResult dataclass (model_id and source_file are filled implicitly;
84
+ # unit_tests_instance is NOT spec-writable — it comes from the matched
85
+ # component, see JsonRecognizer.recognize).
86
+ _VALID_FIELDS = frozenset(
87
+ {
88
+ "n_vars",
89
+ "x_expressions",
90
+ "x_raw",
91
+ "x_reference",
92
+ "error_expected",
93
+ "stop_time",
94
+ "tolerance",
95
+ "method",
96
+ "number_of_intervals",
97
+ "output_interval",
98
+ # PTA.4 — richer-contract fields
99
+ "simulate_only",
100
+ "requested_baselines",
101
+ }
102
+ )
103
+
104
+ # Subset of _VALID_FIELDS that RecognizerResult types as a number. A spec may
105
+ # only write a number into these — see _numeric_or_none. The int subset gets
106
+ # an exact-integer check: n_vars=2.5 is spec garbage, not a count.
107
+ _NUMERIC_FIELDS = frozenset(
108
+ {
109
+ "n_vars",
110
+ "error_expected",
111
+ "stop_time",
112
+ "tolerance",
113
+ "number_of_intervals",
114
+ "output_interval",
115
+ }
116
+ )
117
+ _INT_FIELDS = frozenset({"n_vars", "number_of_intervals"})
118
+
119
+
120
+ def _numeric_or_none(model_id: str, field_name: str, value: Any) -> Any:
121
+ """Return ``value`` if it is a number, else None after warning.
122
+
123
+ ``_coerce_scalar`` is best-effort and falls back to the raw string for
124
+ anything it cannot parse (``StopTime=0.5*3600``). Setting that string on a
125
+ float-typed field pushes the failure into a backend far from its cause, so
126
+ leave the field unset instead — the annotation / test_spec layer can still
127
+ supply a value.
128
+ """
129
+
130
+ def _reject() -> None:
131
+ logger.warning(
132
+ "%s: ignoring non-numeric recognizer value for %s=%r",
133
+ model_id,
134
+ field_name,
135
+ value,
136
+ )
137
+
138
+ # bool is an int subclass AND float(True) == 1.0 — a bare float()
139
+ # fallback would silently turn `true` into 1.0 (A5 review finding 2).
140
+ if isinstance(value, bool):
141
+ _reject()
142
+ return None
143
+ if isinstance(value, (int, float)):
144
+ num: float = value
145
+ else:
146
+ try:
147
+ num = float(value)
148
+ except (TypeError, ValueError):
149
+ _reject()
150
+ return None
151
+ if field_name in _INT_FIELDS:
152
+ if num != int(num):
153
+ _reject()
154
+ return None
155
+ return int(num)
156
+ return num
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Match contexts
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ @dataclass
165
+ class ComponentMatch:
166
+ """A component-instantiation match. Carries the parameter-list text so
167
+ the `parameter` field source can extract from it."""
168
+
169
+ component_class: str
170
+ instance_name: str
171
+ param_text: str
172
+
173
+
174
+ @dataclass
175
+ class ExtendsMatch:
176
+ """An extends match."""
177
+
178
+ extended_class: str
179
+
180
+
181
+ @dataclass
182
+ class ClassNameMatch:
183
+ """A class-name-glob match (PTA-follow.3)."""
184
+
185
+ qualified_name: str
186
+
187
+
188
+ @dataclass
189
+ class AnnotationMatch:
190
+ """A generic-annotation match: the matched annotation's param text."""
191
+
192
+ annotation_name: str
193
+ param_text: str
194
+
195
+
196
+ @dataclass
197
+ class CompositeMatch:
198
+ """A composite of one or more sub-matches (PTA-follow.2: all-of / any-of).
199
+
200
+ Field-source interpreters that need a specific leaf-match shape (e.g.,
201
+ `parameter` needs a ComponentMatch) traverse the composite via
202
+ :func:`_find_match`.
203
+ """
204
+
205
+ matches: list
206
+
207
+
208
+ MatchContext = ComponentMatch | ExtendsMatch | CompositeMatch
209
+
210
+
211
+ def _find_match(match: MatchContext, type_):
212
+ """Return the first sub-match of the given type, traversing composites."""
213
+ if isinstance(match, type_):
214
+ return match
215
+ if isinstance(match, CompositeMatch):
216
+ for m in match.matches:
217
+ found = _find_match(m, type_)
218
+ if found is not None:
219
+ return found
220
+ return None
221
+
222
+
223
+ # Match-type → field-sources allowed mapping, validated at parse time.
224
+ # `parameter` requires a parameter list, which only `component-instantiation`
225
+ # matches provide. Composite types (all-of / any-of) inherit the union of
226
+ # allowed sources across their children — see _allowed_sources_for.
227
+ _MATCH_TYPE_ALLOWED_FIELD_SOURCES = {
228
+ "component-instantiation": frozenset(
229
+ {"parameter", "constant", "experiment-annotation", "annotation"}
230
+ ),
231
+ "extends": frozenset({"constant", "experiment-annotation", "annotation"}),
232
+ "class-name-glob": frozenset({"constant", "experiment-annotation", "annotation"}),
233
+ }
234
+
235
+
236
+ def _collect_leaf_match_types(match_spec: dict) -> set[str]:
237
+ """Walk a match spec and return the set of leaf match types it contains."""
238
+ t = match_spec.get("type")
239
+ if t in ("all-of", "any-of"):
240
+ out: set[str] = set()
241
+ for child in match_spec.get("matchers", []):
242
+ out |= _collect_leaf_match_types(child)
243
+ return out
244
+ return {t} if t else set()
245
+
246
+
247
+ def _allowed_sources_for(match_spec: dict) -> frozenset[str]:
248
+ """Union of field sources allowed for any leaf match type in the spec."""
249
+ leaf_types = _collect_leaf_match_types(match_spec)
250
+ out: set[str] = set()
251
+ for t in leaf_types:
252
+ out |= _MATCH_TYPE_ALLOWED_FIELD_SOURCES.get(t, frozenset())
253
+ return frozenset(out)
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Match interpreter registry
258
+ # ---------------------------------------------------------------------------
259
+
260
+ _MatchFn = Callable[[str, dict], MatchContext | None]
261
+ _MATCH_INTERPRETERS: dict[str, _MatchFn] = {}
262
+
263
+
264
+ def _register_match(type_name: str):
265
+ def deco(fn: _MatchFn) -> _MatchFn:
266
+ _MATCH_INTERPRETERS[type_name] = fn
267
+ return fn
268
+
269
+ return deco
270
+
271
+
272
+ @_register_match("component-instantiation")
273
+ def _match_component_instantiation(content: str, spec: dict) -> ComponentMatch | None:
274
+ full = spec["component_name"]
275
+ segments = full.split(".")
276
+ # Try fully qualified, then progressively shorter tail-suffixes — most
277
+ # specific first so longer matches win when alternatives overlap.
278
+ tails = [".".join(segments[i:]) for i in range(len(segments))]
279
+ pattern_str = "|".join(re.escape(t) for t in tails)
280
+ pattern = re.compile(rf"\b(?:{pattern_str})\s+(\w+)\s*\(", re.DOTALL)
281
+ # Strip comments + string literals so prose mentioning the component
282
+ # class doesn't false-trigger. The stripper is 1:1 (offset-preserving),
283
+ # so the balanced-paren scan runs on stripped text but the param text is
284
+ # sliced from the ORIGINAL content — review 2026-07-06 finding 50:
285
+ # string-valued parameters (algorithm="Dassl") were unextractable from
286
+ # the stripped text.
287
+ stripped = _strip_modelica_literals(content)
288
+ m = pattern.search(stripped)
289
+ if not m:
290
+ return None
291
+ instance_name = m.group(1)
292
+ matched_class = m.group(0).rsplit(instance_name, 1)[0].strip()
293
+ paren_open = m.end() - 1
294
+ depth = 0
295
+ end = paren_open
296
+ for i in range(paren_open, len(stripped)):
297
+ if stripped[i] == "(":
298
+ depth += 1
299
+ elif stripped[i] == ")":
300
+ depth -= 1
301
+ if depth == 0:
302
+ end = i
303
+ break
304
+ return ComponentMatch(
305
+ component_class=matched_class,
306
+ instance_name=instance_name,
307
+ param_text=content[paren_open + 1 : end],
308
+ )
309
+
310
+
311
+ @_register_match("extends")
312
+ def _match_extends(content: str, spec: dict) -> ExtendsMatch | None:
313
+ pattern_glob = spec["class_pattern"]
314
+ # Strip comments + string literals first — prose like "extends
315
+ # ModelicaTestingLib.Icons.Example" inside a doc annotation would
316
+ # otherwise register as a real extends, misidentifying icon classes
317
+ # as tests.
318
+ stripped = _strip_modelica_literals(content)
319
+ for m in re.finditer(r"\bextends\s+([\w.]+)", stripped):
320
+ extended = m.group(1)
321
+ if fnmatch.fnmatchcase(extended, pattern_glob):
322
+ return ExtendsMatch(extended_class=extended)
323
+ return None
324
+
325
+
326
+ # NOTE: the comment/string stripper is shared with mo_parser
327
+ # (``_strip_modelica_literals``) — review 2026-07-06 findings 48/50: the
328
+ # module used to carry its own copy whose replacements shifted offsets,
329
+ # breaking original-text extraction of string-valued parameters.
330
+
331
+
332
+ @_register_match("class-name-glob")
333
+ def _match_class_name_glob(content: str, spec: dict) -> ClassNameMatch | None:
334
+ """Match a class whose fully qualified name matches an fnmatch glob."""
335
+ pattern_glob = spec["class_pattern"]
336
+ within = _extract_within(content)
337
+ class_name = _extract_model_name(content)
338
+ if not class_name:
339
+ return None
340
+ qualified = f"{within}.{class_name}" if within else class_name
341
+ if fnmatch.fnmatchcase(qualified, pattern_glob):
342
+ return ClassNameMatch(qualified_name=qualified)
343
+ return None
344
+
345
+
346
+ @_register_match("all-of")
347
+ def _match_all_of(content: str, spec: dict) -> CompositeMatch | None:
348
+ """All child matchers must match; result aggregates all child contexts."""
349
+ matches = []
350
+ for child_spec in spec.get("matchers", []):
351
+ child_fn = _MATCH_INTERPRETERS.get(child_spec.get("type"))
352
+ if child_fn is None:
353
+ return None
354
+ result = child_fn(content, child_spec)
355
+ if result is None:
356
+ return None
357
+ matches.append(result)
358
+ return CompositeMatch(matches=matches) if matches else None
359
+
360
+
361
+ @_register_match("any-of")
362
+ def _match_any_of(content: str, spec: dict) -> CompositeMatch | None:
363
+ """First matching child wins; result wraps just that child."""
364
+ for child_spec in spec.get("matchers", []):
365
+ child_fn = _MATCH_INTERPRETERS.get(child_spec.get("type"))
366
+ if child_fn is None:
367
+ continue
368
+ result = child_fn(content, child_spec)
369
+ if result is not None:
370
+ return CompositeMatch(matches=[result])
371
+ return None
372
+
373
+
374
+ # ---------------------------------------------------------------------------
375
+ # Field-source interpreter registry
376
+ # ---------------------------------------------------------------------------
377
+
378
+ _FieldFn = Callable[[str, MatchContext, dict], Any]
379
+ _FIELD_INTERPRETERS: dict[str, _FieldFn] = {}
380
+
381
+
382
+ def _register_field(source_name: str):
383
+ def deco(fn: _FieldFn) -> _FieldFn:
384
+ _FIELD_INTERPRETERS[source_name] = fn
385
+ return fn
386
+
387
+ return deco
388
+
389
+
390
+ def _coerce_scalar(raw: str) -> Any:
391
+ """Best-effort coerce a raw token to bool / int / float / str."""
392
+ raw = raw.strip().strip('"')
393
+ if not raw:
394
+ return None
395
+ if raw == "true":
396
+ return True
397
+ if raw == "false":
398
+ return False
399
+ try:
400
+ if "." in raw or "e" in raw or "E" in raw:
401
+ return float(raw)
402
+ return int(raw)
403
+ except ValueError:
404
+ try:
405
+ return float(raw)
406
+ except ValueError:
407
+ return raw
408
+
409
+
410
+ def _extract_param_value(param_text: str, name: str, shape: str) -> Any:
411
+ """Extract a parameter value of the given shape from a parameter list."""
412
+ if shape == "array":
413
+ m = re.search(rf"\b{re.escape(name)}\s*=\s*\{{", param_text)
414
+ if not m:
415
+ return None
416
+ raw = _extract_balanced_braces(param_text, m.end() - 1)
417
+ return _parse_x_expressions(raw)
418
+ if shape == "array_floats":
419
+ m = re.search(rf"\b{re.escape(name)}\s*=\s*\{{", param_text)
420
+ if not m:
421
+ return None
422
+ raw = _extract_balanced_braces(param_text, m.end() - 1)
423
+ return _parse_float_list(raw)
424
+ # scalar — try quoted string first, then bare token
425
+ m = re.search(rf'\b{re.escape(name)}\s*=\s*"([^"]*)"', param_text)
426
+ if m:
427
+ return m.group(1)
428
+ m = re.search(rf"\b{re.escape(name)}\s*=\s*([^,\s)]+)", param_text)
429
+ if not m:
430
+ return None
431
+ return _coerce_scalar(m.group(1))
432
+
433
+
434
+ @_register_field("parameter")
435
+ def _field_parameter(content: str, match: MatchContext, spec: dict) -> Any:
436
+ cm = _find_match(match, ComponentMatch)
437
+ if cm is None:
438
+ return None # composite that didn't include a component-instantiation
439
+ return _extract_param_value(
440
+ cm.param_text, spec["name"], spec.get("shape", "scalar")
441
+ )
442
+
443
+
444
+ @_register_field("constant")
445
+ def _field_constant(content: str, match: MatchContext, spec: dict) -> Any:
446
+ return spec["value"]
447
+
448
+
449
+ @_register_field("annotation")
450
+ def _field_annotation(content: str, match: MatchContext, spec: dict) -> Any:
451
+ """Extract a parameter from an arbitrary Modelica annotation block.
452
+
453
+ Spec: ``{"from": "annotation", "annotation": "<name>", "name": "<key>"}``
454
+ where ``annotation`` is the annotation block name (e.g. ``__MyVendor_Test``)
455
+ and ``name`` is the parameter inside it.
456
+ """
457
+ ann_name = spec["annotation"]
458
+ # Scan stripped text (a commented-out annotation must not match; paren
459
+ # chars inside strings must not break the balance scan) but slice the
460
+ # ORIGINAL content so string values survive — review 2026-07-06
461
+ # findings 48/50.
462
+ stripped = _strip_modelica_literals(content)
463
+ m = re.search(
464
+ rf"\b{re.escape(ann_name)}\s*\(",
465
+ stripped,
466
+ )
467
+ if not m:
468
+ return None
469
+ # Extract balanced parentheses
470
+ paren_open = m.end() - 1
471
+ depth = 0
472
+ end = paren_open
473
+ for i in range(paren_open, len(stripped)):
474
+ if stripped[i] == "(":
475
+ depth += 1
476
+ elif stripped[i] == ")":
477
+ depth -= 1
478
+ if depth == 0:
479
+ end = i
480
+ break
481
+ param_text = content[paren_open + 1 : end]
482
+ return _extract_param_value(param_text, spec["name"], spec.get("shape", "scalar"))
483
+
484
+
485
+ @_register_field("experiment-annotation")
486
+ def _field_experiment_annotation(content: str, match: MatchContext, spec: dict) -> Any:
487
+ # review 2026-07-06 finding 48: locate experiment(...) on stripped text
488
+ # (comments must not shadow the real annotation) with a balanced-paren
489
+ # scan (no [^)]* truncation); extract values from the ORIGINAL span so
490
+ # quoted strings survive.
491
+ span = _find_experiment_span(_strip_modelica_literals(content))
492
+ if span is None:
493
+ return None
494
+ name = spec["name"]
495
+ if not name:
496
+ return None
497
+ pt = content[span[0] : span[1]]
498
+ shape = spec.get("shape", "scalar")
499
+ # Try the user-given name, then alt-case first letter — Modelica writes
500
+ # both StopTime and stopTime interchangeably.
501
+ candidates = [name]
502
+ if name[0].isalpha():
503
+ alt = name[0].swapcase() + name[1:]
504
+ if alt != name:
505
+ candidates.append(alt)
506
+ for candidate in candidates:
507
+ value = _extract_param_value(pt, candidate, shape)
508
+ if value is not None:
509
+ return value
510
+ return None
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # JsonRecognizer + parser
515
+ # ---------------------------------------------------------------------------
516
+
517
+
518
+ class JsonRecognizer(Recognizer):
519
+ """Recognizer configured by a JSON spec."""
520
+
521
+ def __init__(self, spec: dict):
522
+ self.name: str = spec["name"]
523
+ self.applies_to = frozenset(spec.get("applies_to", ["modelica"]))
524
+ self._match_spec: dict = spec["match"]
525
+ self._field_specs: dict = spec.get("fields", {})
526
+ # PTA-follow.1: optional path filters. Globs match against the path
527
+ # relative to the discovery base (config.library_dir for modelica).
528
+ self._paths_include: list[str] = list(spec.get("paths_include", []))
529
+ self._paths_exclude: list[str] = list(spec.get("paths_exclude", []))
530
+
531
+ def applies_to_path(self, source_file: Path, base: Path) -> bool:
532
+ if not self._paths_include and not self._paths_exclude:
533
+ return True
534
+ try:
535
+ rel = source_file.relative_to(base).as_posix()
536
+ except ValueError:
537
+ # File isn't under base — exclude conservatively.
538
+ return False
539
+ if self._paths_include and not any(
540
+ fnmatch.fnmatchcase(rel, g) for g in self._paths_include
541
+ ):
542
+ return False
543
+ return not any(fnmatch.fnmatchcase(rel, g) for g in self._paths_exclude)
544
+
545
+ def recognize(self, source_file: Path) -> RecognizerResult | None:
546
+ try:
547
+ content = source_file.read_text(encoding="utf-8", errors="replace")
548
+ except OSError:
549
+ return None
550
+ match_fn = _MATCH_INTERPRETERS[self._match_spec["type"]]
551
+ match_ctx = match_fn(content, self._match_spec)
552
+ if match_ctx is None:
553
+ return None
554
+ within = _extract_within(content)
555
+ model_name = _extract_model_name(content)
556
+ if not model_name:
557
+ return None
558
+ # Defensive: skip if the matched component class IS the class being
559
+ # defined here (the bundled recognizer hits this when the UnitTests
560
+ # component's documentation contains example usage; user-defined
561
+ # recognizers can hit the same on their template's defining file).
562
+ cm = _find_match(match_ctx, ComponentMatch)
563
+ if cm is not None:
564
+ short_class = cm.component_class.rsplit(".", 1)[-1]
565
+ if short_class == model_name:
566
+ return None
567
+ model_id = f"{within}.{model_name}" if within else model_name
568
+ result = RecognizerResult(model_id=model_id, source_file=source_file)
569
+ from_component: set[str] = set()
570
+ for field_name, field_spec in self._field_specs.items():
571
+ field_fn = _FIELD_INTERPRETERS[field_spec["from"]]
572
+ value = field_fn(content, match_ctx, field_spec)
573
+ if value is None:
574
+ continue
575
+ if field_name in _NUMERIC_FIELDS:
576
+ value = _numeric_or_none(model_id, field_name, value)
577
+ if value is None:
578
+ continue
579
+ setattr(result, field_name, value)
580
+ if field_spec["from"] == "parameter":
581
+ from_component.add(field_name)
582
+ # The instance name is the matched component's, never the spec's: a
583
+ # recognizer that takes the tracked variables out of a component —
584
+ # how MANY of them or WHICH expressions, either is enough — names
585
+ # that component's instance too, because the result file names them
586
+ # ``<instance>.x[i]``. Keyed off the count as well as the
587
+ # expressions: a recognizer that reads only ``n_vars`` off the
588
+ # component left the instance unset, and every one of its variables
589
+ # was then looked for under the default ``unitTests.x[i]``.
590
+ #
591
+ # It is the SOURCE of those two fields that decides, not their
592
+ # value. A recognizer whose ``n_vars`` is a ``constant`` says
593
+ # nothing about where the variables live, and the component it
594
+ # matched on may be any component at all — matching
595
+ # ``Modelica.Blocks.Sources.Sine sine`` to find tests named the
596
+ # instance ``sine``, and every variable of every such test went
597
+ # looking for ``sine.x[i]`` and was missing.
598
+ if cm is not None and from_component & {"n_vars", "x_expressions"}:
599
+ result.unit_tests_instance = cm.instance_name
600
+ return result
601
+
602
+
603
+ def _validate_match_spec(rec_name: str, match: dict) -> None:
604
+ """Validate a (possibly composite) match spec, raising on error."""
605
+ if not isinstance(match, dict) or "type" not in match:
606
+ raise RecognizerSpecError(
607
+ f"recognizer '{rec_name}' has malformed match (missing 'type')"
608
+ )
609
+ mtype = match["type"]
610
+ if mtype not in _MATCH_INTERPRETERS:
611
+ raise RecognizerSpecError(
612
+ f"recognizer '{rec_name}' has unknown match type '{mtype}'. "
613
+ f"Known: {sorted(_MATCH_INTERPRETERS)}"
614
+ )
615
+ if mtype in ("all-of", "any-of"):
616
+ children = match.get("matchers")
617
+ if not isinstance(children, list) or not children:
618
+ raise RecognizerSpecError(
619
+ f"recognizer '{rec_name}' match.{mtype} requires 'matchers' (non-empty list)"
620
+ )
621
+ for child in children:
622
+ _validate_match_spec(rec_name, child)
623
+ return
624
+ if mtype == "component-instantiation" and "component_name" not in match:
625
+ raise RecognizerSpecError(
626
+ f"recognizer '{rec_name}' match.component-instantiation requires 'component_name'"
627
+ )
628
+ if mtype == "extends" and "class_pattern" not in match:
629
+ raise RecognizerSpecError(
630
+ f"recognizer '{rec_name}' match.extends requires 'class_pattern'"
631
+ )
632
+ if mtype == "class-name-glob" and "class_pattern" not in match:
633
+ raise RecognizerSpecError(
634
+ f"recognizer '{rec_name}' match.class-name-glob requires 'class_pattern'"
635
+ )
636
+
637
+
638
+ def parse_recognizer_spec(spec: dict) -> Recognizer:
639
+ """Validate and parse a JSON recognizer spec into a Recognizer instance."""
640
+ if not isinstance(spec, dict):
641
+ raise RecognizerSpecError(
642
+ f"recognizer spec must be a dict, got {type(spec).__name__}"
643
+ )
644
+ if "name" not in spec:
645
+ raise RecognizerSpecError("recognizer spec missing required field 'name'")
646
+ name = spec["name"]
647
+ if "match" not in spec:
648
+ raise RecognizerSpecError(f"recognizer '{name}' missing required field 'match'")
649
+ match = spec["match"]
650
+ _validate_match_spec(name, match)
651
+ match_type = match["type"]
652
+ fields = spec.get("fields", {})
653
+ if not isinstance(fields, dict):
654
+ raise RecognizerSpecError(f"recognizer '{name}' fields must be a dict")
655
+ allowed_sources = _allowed_sources_for(match)
656
+ for fname, fspec in fields.items():
657
+ if fname not in _VALID_FIELDS:
658
+ raise RecognizerSpecError(
659
+ f"recognizer '{name}' targets unknown field '{fname}'. "
660
+ f"Known: {sorted(_VALID_FIELDS)}"
661
+ )
662
+ if not isinstance(fspec, dict) or "from" not in fspec:
663
+ raise RecognizerSpecError(
664
+ f"recognizer '{name}' field '{fname}' missing 'from'"
665
+ )
666
+ source = fspec["from"]
667
+ if source not in _FIELD_INTERPRETERS:
668
+ raise RecognizerSpecError(
669
+ f"recognizer '{name}' field '{fname}' uses unknown source '{source}'. "
670
+ f"Known: {sorted(_FIELD_INTERPRETERS)}"
671
+ )
672
+ if source not in allowed_sources:
673
+ raise RecognizerSpecError(
674
+ f"recognizer '{name}' field '{fname}' source '{source}' isn't compatible "
675
+ f"with match type '{match_type}'. Allowed for this match type: "
676
+ f"{sorted(allowed_sources)}"
677
+ )
678
+ if source == "parameter" and "name" not in fspec:
679
+ raise RecognizerSpecError(
680
+ f"recognizer '{name}' field '{fname}' (source=parameter) requires 'name'"
681
+ )
682
+ if source == "constant" and "value" not in fspec:
683
+ raise RecognizerSpecError(
684
+ f"recognizer '{name}' field '{fname}' (source=constant) requires 'value'"
685
+ )
686
+ if source == "experiment-annotation" and "name" not in fspec:
687
+ raise RecognizerSpecError(
688
+ f"recognizer '{name}' field '{fname}' "
689
+ f"(source=experiment-annotation) requires 'name'"
690
+ )
691
+ if source == "annotation":
692
+ if "annotation" not in fspec:
693
+ raise RecognizerSpecError(
694
+ f"recognizer '{name}' field '{fname}' "
695
+ f"(source=annotation) requires 'annotation'"
696
+ )
697
+ if "name" not in fspec:
698
+ raise RecognizerSpecError(
699
+ f"recognizer '{name}' field '{fname}' "
700
+ f"(source=annotation) requires 'name'"
701
+ )
702
+ return JsonRecognizer(spec)