|
52 | 52 | REPO_ROOT / "src" / "bundles", |
53 | 53 | ) |
54 | 54 |
|
| 55 | +# A class is "ambiguous" if it lives in this many or more bundle folders. |
| 56 | +_AMBIGUITY_THRESHOLD = 2 |
| 57 | + |
55 | 58 |
|
56 | 59 | def _iter_component_files(roots: Iterable[Path]) -> Iterable[Path]: |
57 | 60 | """Yield every ``.py`` file under each root, skipping caches and dunders.""" |
@@ -126,6 +129,24 @@ def _bare_name_entries(table: dict) -> list[dict]: |
126 | 129 | return [e for e in entries if isinstance(e, dict) and e.get("bare_class_name") is not None] |
127 | 130 |
|
128 | 131 |
|
| 132 | +def _ambiguous_bare_name_set(table: dict) -> set[str]: |
| 133 | + """Return the set of bare names registered as ``ambiguous_bare_names``. |
| 134 | +
|
| 135 | + These are the names the rewriter surfaces ``component-name-ambiguous`` for; |
| 136 | + any bare name found in 2+ bundle folders must be either here OR have a |
| 137 | + specific import_path entry per variant so saved flows still resolve. |
| 138 | + """ |
| 139 | + ambig = table.get("ambiguous_bare_names", []) |
| 140 | + if not isinstance(ambig, list): |
| 141 | + msg = "migration table 'ambiguous_bare_names' must be a list" |
| 142 | + raise TypeError(msg) |
| 143 | + out: set[str] = set() |
| 144 | + for entry in ambig: |
| 145 | + if isinstance(entry, dict) and isinstance(entry.get("name"), str): |
| 146 | + out.add(entry["name"]) |
| 147 | + return out |
| 148 | + |
| 149 | + |
129 | 150 | def find_violations( |
130 | 151 | bare_name_entries: list[dict], |
131 | 152 | class_to_bundles: dict[str, set[str]], |
@@ -156,6 +177,46 @@ def find_violations( |
156 | 177 | return violations |
157 | 178 |
|
158 | 179 |
|
| 180 | +def find_unregistered_ambiguities( |
| 181 | + class_to_bundles: dict[str, set[str]], |
| 182 | + ambiguous_names: set[str], |
| 183 | + *, |
| 184 | + only_components: bool = True, |
| 185 | +) -> list[str]: |
| 186 | + """Return one message per ambiguous Component class missing from the marker list. |
| 187 | +
|
| 188 | + Per the LE-1020 contract, a bare class name that exists in 2+ bundle |
| 189 | + folders must surface ``component-name-ambiguous`` at flow-load time. |
| 190 | + The rewriter's only durable signal is the ``ambiguous_bare_names`` |
| 191 | + list; without an entry there the value falls through to |
| 192 | + ``component-not-found-with-hint``, which is the wrong code. |
| 193 | +
|
| 194 | + When ``only_components`` is true (the default) we restrict the check to |
| 195 | + classes whose name ends in ``Component`` -- that's the population a |
| 196 | + saved flow would reference. Utility schemas / method enums that share |
| 197 | + a class name across bundles are not reachable from a flow JSON, so we |
| 198 | + do not require markers for them. |
| 199 | + """ |
| 200 | + out: list[str] = [] |
| 201 | + for class_name, bundles in sorted(class_to_bundles.items()): |
| 202 | + if len(bundles) < _AMBIGUITY_THRESHOLD: |
| 203 | + continue |
| 204 | + if only_components and not class_name.endswith("Component"): |
| 205 | + continue |
| 206 | + if class_name in ambiguous_names: |
| 207 | + continue |
| 208 | + sorted_bundles = sorted(bundles) |
| 209 | + out.append( |
| 210 | + f"ambiguous Component class {class_name!r} appears in " |
| 211 | + f"{len(bundles)} bundle folders ({', '.join(sorted_bundles)}) " |
| 212 | + f"but is not registered in ``ambiguous_bare_names``. Add an " |
| 213 | + f"entry so the deserializer surfaces ``component-name-ambiguous`` " |
| 214 | + f"with the candidate targets, instead of falling through to " |
| 215 | + f"``component-not-found-with-hint``." |
| 216 | + ) |
| 217 | + return out |
| 218 | + |
| 219 | + |
159 | 220 | def main(argv: list[str] | None = None) -> int: |
160 | 221 | parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) |
161 | 222 | parser.add_argument( |
@@ -190,35 +251,40 @@ def main(argv: list[str] | None = None) -> int: |
190 | 251 |
|
191 | 252 | try: |
192 | 253 | bare_entries = _bare_name_entries(table) |
| 254 | + ambiguous_names = _ambiguous_bare_name_set(table) |
193 | 255 | except TypeError as exc: |
194 | 256 | print(f"error: {exc}", file=sys.stderr) |
195 | 257 | return 2 |
196 | 258 |
|
197 | | - if not bare_entries: |
198 | | - print("ok: no bare_class_name entries to check.") |
199 | | - return 0 |
200 | | - |
201 | 259 | roots = args.components_root if args.components_root else list(DEFAULT_COMPONENT_ROOTS) |
202 | 260 | try: |
203 | 261 | class_to_bundles = build_class_to_bundles(roots) |
204 | 262 | except RuntimeError as exc: |
205 | 263 | print(f"error: {exc}", file=sys.stderr) |
206 | 264 | return 2 |
207 | 265 |
|
208 | | - violations = find_violations(bare_entries, class_to_bundles) |
| 266 | + violations: list[str] = [] |
| 267 | + if bare_entries: |
| 268 | + violations.extend(find_violations(bare_entries, class_to_bundles)) |
| 269 | + |
| 270 | + # Always run the ambiguity-coverage check: any Component class found in |
| 271 | + # 2+ bundle folders must have an ``ambiguous_bare_names`` entry so the |
| 272 | + # deserializer surfaces the right typed code. |
| 273 | + violations.extend(find_unregistered_ambiguities(class_to_bundles, ambiguous_names)) |
| 274 | + |
209 | 275 | if violations: |
210 | 276 | print( |
211 | | - "error: bare-name migration entries must map to globally-unique classes; refusing the following:", |
| 277 | + "error: bare-name migration coverage failed; refusing the following:", |
212 | 278 | file=sys.stderr, |
213 | 279 | ) |
214 | 280 | for v in violations: |
215 | 281 | print(f" - {v}", file=sys.stderr) |
216 | 282 | return 1 |
217 | 283 |
|
218 | 284 | print( |
219 | | - f"ok: every bare_class_name entry maps to a globally-unique class " |
220 | | - f"({len(bare_entries)} entries checked against " |
221 | | - f"{sum(len(b) for b in class_to_bundles.values())} class declarations)." |
| 285 | + f"ok: {len(bare_entries)} bare_class_name entries checked, " |
| 286 | + f"{len(ambiguous_names)} ambiguous-bare-name markers checked, " |
| 287 | + f"against {sum(len(b) for b in class_to_bundles.values())} class declarations." |
222 | 288 | ) |
223 | 289 | return 0 |
224 | 290 |
|
|
0 commit comments