77allowed (the runtime does not care about order); changing the ``target``,
88``legacy_*`` field, or the value any entry maps from is **not**.
99
10+ The same invariant applies to the ``ambiguous_bare_names`` list: a marker
11+ may not be removed once published, and its ``candidates`` list may only
12+ grow -- shrinking it would regress a saved flow that previously surfaced
13+ ``component-name-ambiguous`` to ``component-not-found-with-hint``.
14+
1015Usage::
1116
1217 python scripts/migrate/check_migration_append_only.py
@@ -75,7 +80,12 @@ def _git_show(ref: str, relpath: str) -> str | None:
7580 return completed .stdout
7681
7782
78- def _parse (raw : str , * , source : str ) -> list [dict ]:
83+ def _parse (raw : str , * , source : str ) -> tuple [list [dict ], list [dict ]]:
84+ """Return ``(entries, ambiguous_bare_names)`` from a migration-table JSON.
85+
86+ Both lists default to empty when the field is absent so this script can
87+ compare across baselines that pre-date a given field.
88+ """
7989 try :
8090 data = json .loads (raw )
8191 except json .JSONDecodeError as exc :
@@ -88,7 +98,11 @@ def _parse(raw: str, *, source: str) -> list[dict]:
8898 if not isinstance (entries , list ):
8999 print (f"error: { source } entries field must be a list" , file = sys .stderr )
90100 raise SystemExit (2 )
91- return entries
101+ ambig = data .get ("ambiguous_bare_names" , [])
102+ if not isinstance (ambig , list ):
103+ print (f"error: { source } ambiguous_bare_names field must be a list" , file = sys .stderr )
104+ raise SystemExit (2 )
105+ return entries , ambig
92106
93107
94108def _compare (baseline : list [dict ], current : list [dict ]) -> list [str ]:
@@ -130,6 +144,60 @@ def _compare(baseline: list[dict], current: list[dict]) -> list[str]:
130144 return violations
131145
132146
147+ def _ambig_name (entry : dict ) -> str | None :
148+ """Return the bare-name key of an ambiguity marker, or ``None`` if malformed."""
149+ name = entry .get ("name" )
150+ return name if isinstance (name , str ) else None
151+
152+
153+ def _ambig_candidates (entry : dict ) -> set [str ]:
154+ raw = entry .get ("candidates" , [])
155+ if not isinstance (raw , list ):
156+ return set ()
157+ return {c for c in raw if isinstance (c , str )}
158+
159+
160+ def _compare_ambiguous (baseline : list [dict ], current : list [dict ]) -> list [str ]:
161+ """Return human-readable violations for ambiguous_bare_names changes.
162+
163+ Append-only contract:
164+ * No marker may be removed once published.
165+ * The candidate set may only grow; removing a candidate would
166+ regress a saved flow that previously surfaced
167+ ``component-name-ambiguous`` (with that target as one of the
168+ fix-hint options) to ``component-not-found-with-hint``.
169+ """
170+ violations : list [str ] = []
171+ current_by_name : dict [str , dict ] = {}
172+ for entry in current :
173+ name = _ambig_name (entry )
174+ if name is None :
175+ violations .append (f"current ambiguous_bare_names entry malformed (no name): { entry !r} " )
176+ continue
177+ if name in current_by_name :
178+ violations .append (f"duplicate ambiguous_bare_names entry in current table: name={ name !r} " )
179+ continue
180+ current_by_name [name ] = entry
181+
182+ for entry in baseline :
183+ name = _ambig_name (entry )
184+ if name is None :
185+ violations .append (f"baseline ambiguous_bare_names entry malformed (no name): { entry !r} " )
186+ continue
187+ match = current_by_name .get (name )
188+ if match is None :
189+ violations .append (
190+ f"ambiguous_bare_names marker removed: name={ name !r} (added in { entry .get ('added_in' )!r} )"
191+ )
192+ continue
193+ baseline_candidates = _ambig_candidates (entry )
194+ current_candidates = _ambig_candidates (match )
195+ missing = baseline_candidates - current_candidates
196+ if missing :
197+ violations .append (f"ambiguous_bare_names candidates shrunk for name={ name !r} : removed { sorted (missing )!r} " )
198+ return violations
199+
200+
133201def main (argv : list [str ] | None = None ) -> int :
134202 parser = argparse .ArgumentParser (description = __doc__ )
135203 parser .add_argument (
@@ -170,9 +238,10 @@ def main(argv: list[str] | None = None) -> int:
170238 print (f"no baseline migration table at { args .base } :{ TABLE_RELPATH } ; nothing to compare." )
171239 return 0
172240
173- baseline = _parse (baseline_raw , source = f"{ args .base } :{ TABLE_RELPATH } " )
174- current = _parse (current_raw , source = str (args .current ))
175- violations = _compare (baseline , current )
241+ baseline_entries , baseline_ambig = _parse (baseline_raw , source = f"{ args .base } :{ TABLE_RELPATH } " )
242+ current_entries , current_ambig = _parse (current_raw , source = str (args .current ))
243+ violations = _compare (baseline_entries , current_entries )
244+ violations .extend (_compare_ambiguous (baseline_ambig , current_ambig ))
176245
177246 if violations :
178247 print (
@@ -184,7 +253,8 @@ def main(argv: list[str] | None = None) -> int:
184253 return 1
185254 print (
186255 f"ok: migration table is append-only "
187- f"({ len (current )} entries; { len (current ) - len (baseline )} added since baseline)"
256+ f"(entries: { len (current_entries )} , +{ len (current_entries ) - len (baseline_entries )} ; "
257+ f"ambiguous_bare_names: { len (current_ambig )} , +{ len (current_ambig ) - len (baseline_ambig )} )"
188258 )
189259 return 0
190260
0 commit comments