lint: flag defined names Excel repairs — fold-duplicate and definitely-illegal names (GH-528, GH-529) - #530
Conversation
Review —
|
| A | B | folds to | why |
|---|---|---|---|
Area1 |
Area① |
area1 |
U+2460 → 1 (<circle>) |
M2 |
M² |
m2 |
U+00B2 → 2 (<super>) |
m2 |
㎡ |
m2 |
U+33A1 → m2 (<square>) |
No1 |
№1 |
no1 |
U+2116 → No (<compat>) |
I |
Ⅰ |
i |
U+2160 → I (<compat>) |
ff |
ff |
ff |
U+FB00 → ff (ligature) |
Circled digits (①②③) and squared units (㎡ ㎏ ㎜) are extremely common in exactly the JP-legacy corpora this rule targets — 面積① alongside 面積1 is an ordinary sheet, not a fossil. That is a false positive at the ship gate, which is the one outcome the PR is built to avoid.
The narrow fix is to fold only the width/kana classes rather than everything NFKC knows about — e.g. map U+FF01–FF5E → ASCII (c - 0xFEE0), U+FF61–FF9F halfwidth katakana → fullwidth (the voiced-mark composition is the only fiddly part), and leave every other codepoint alone. That keeps HTML/html, g/g and the kana table while dropping the compatibility-decomposition blast radius. Cheaper interim option: keep NFKC but apply it per-character, only to characters whose Character.UnicodeBlock is HALFWIDTH_AND_FULLWIDTH_FORMS.
Either way, re-run the 25k calibration corpus afterwards — the numbers to confirm are that the surviving-name set still yields zero groups and that no ①/㎡-bearing pair has appeared.
2. Collision-finding order is hash-ordered on ties (WorkbookLint.scala:1108)
.sortBy((names, _) => names.headOption.getOrElse("")) sorts on the group's first name only, and sortBy is stable, so ties retain groupBy(...).toVector's order — i.e. immutable-HashMap iteration order. Ties are reachable: a book carrying g + g at workbook scope and g + g on localSheetId="0" produces two groups both headed "g", and their relative order in the output is whatever the hash layout says (and can shift when an unrelated key enters the map, or on a Scala version bump). Given deterministic output is a stated non-negotiable, make the key total:
.sortBy((names, scope) => (names.headOption.getOrElse(""), scope.getOrElse("")))3. Messages are unbounded on the collision path
illegal truncates names to .take(40); collisions does not. On the incident corpus those two facts combine badly: a group of n fold-equal 186-char fossil names emits one message containing all n of them in full. Worth capping symmetrically — first ~5 names, each .take(40), plus +N more. Separately, .take(40) has no truncation marker, so Defined name "…40 chars…" is 256 characters long reads like a contradiction; appending … when truncated fixes it.
4. Control characters are echoed raw to the terminal
LintCommands.renderText (LintCommands.scala:19) interpolates f.message straight to stdout, and the contains control characters finding puts the offending name inside that message. So linting a hostile or merely broken third-party workbook can emit ESC sequences to the user's terminal — from the one finding whose entire premise is "this string contains control characters". JSON output is safe (ujson escapes). Suggest escaping non-printables to \uXXXX when building the message (belt-and-braces: in renderText too). Low severity, but this is a "run it on files you did not write" tool.
5. Smaller things
- Missing/empty
name(:1102):getOrElse("")means two<definedName>elements with nonameattribute report as a collision of""/"", while a single empty name — which Excel also refuses — goes unflagged. An explicit "has no name" finding reads better than folding it into the collision path. kanaBaseFoldconstruction (:1069):grouped(2).map(p => p(0) -> p(1))throwsStringIndexOutOfBoundsExceptionduring object initialization if that literal ever ends up odd-length, surfacing asExceptionInInitializerErrorfromWorkbookLintrather than anXLResult. The 25 pairs read correctly today (ぁあ … ヶケ, both scripts covered), but given the totality charter an explicitMap('ぁ' -> 'あ', …)— or.grouped(2).collect { case s if s.length == 2 => s(0) -> s(1) }— removes the trap for the next editor.- One finding per problem (
:1128): a name that is both over-long and whitespace-bearing yields two findings for one name. Defensible, but neither documented nor tested; joining the problems with;into a single finding may read better at the ship gate. - Plural agreement:
"$n defined names collide … — Excel repairs the file by removing the named range"— singular tail, plural subject. "by removing all but one of them" is both grammatical and more accurate about what Excel actually does. name.lengthvs code points (:1122): counts UTF-16 units, so 200 astral characters trip the 255 cap. Almost certainly matches Excel (UTF-16 internally too), but worth a word in the comment since it is a deliberate choice.
6. GH-529: consider a prefix predicate over an enumerated set
externalLinkPathTypes (:519) is now seven literals, and this PR exists partly because the previous four did not cover the wild. Every Microsoft member is …/relationships/xlExternalLinkPath/<something>, and the test is only ever "is this an acceptable externalBook path type" — a startsWith on that family prefix (plus the ECMA type) would have pre-empted #529 and the next variant too. That means widening RelRef.expectedTypes from Set[String] to a predicate, so it is a follow-up rather than a blocker — but enumerate-one-per-incident is going to keep costing field incidents. If the set stays, a one-line comment on why both xlStartupPath and xlStartup exist (which producer emits which?) would help the next reader.
7. Test coverage
The seven new tests hit the important shapes, and including a scope-shadowing negative is the right instinct. Gaps I would fill:
- The NFKC boundary — a negative test pinning
Area1vsArea①(andm2vs㎡) as not a collision. That is the calibration claim itself; without it, item 1 can regress silently in either direction. - Halfwidth katakana —
アvsアis the headline width claim for kana, but the only width test uses fullwidth ASCIIg. Add a halfwidth-kana pair, ideally a voiced one, since that is where per-character width folding gets interesting. - Control characters — documented in
cli.md, theMain.scalahelp text and the CHANGELOG; untested. - Multi-scope determinism — the item-2 case (same head name, two scopes) asserting a fixed finding order.
- A >2-member group — the message says
"$n defined names"; onlyn = 2is exercised. - Whitespace flavors — U+00A0 is not
Char.isWhitespacein Java, so it slips the check today, while NFKC would have mapped it to a plain space. Whichever behavior you want, pin it; right now it is accidental.
Open question
Windows' Japanese string comparison also has NORM_IGNOREKANATYPE (hiragana ≡ katakana). Does the 25k corpus contain a surviving あ/ア pair in the same scope? If it does, that is good evidence Excel's name comparison does not ignore kana type and the current fold is complete — worth recording in the comment either way, since it is the obvious next question a reader will have about the fold's shape.
Verdict
The rule is the right rule, the calibration methodology is exemplary, and the docs/CHANGELOG/skill/help updates are complete and consistent (the 0.19.2 section placement matches the stacked #525 entry, and plugin/skills/xl-cli/SKILL.md is in sync). Item 1 is worth fixing before this ships — a lint that flags 面積① next to 面積1 gets switched off, and then it catches nothing at all. Items 2–4 are quick. The rest is polish or follow-up.
🤖 Reviewed with Claude Code
…finitely-illegal names (GH-528, GH-529) New defined-name-invalid category on workbook.xml, driven by the second Excel-repair field incident in two days: - Names in the SAME scope that collide under Excel's case-, width- and kana-size-insensitive comparison (g/g, html/HTML, ぁ/あ) — Excel removes one of each group, sometimes WITHOUT logging it (the first incident's file carried 25 such groups; its repair log never mentioned named ranges). The fold is NFKC + ROOT lowercase + a small-kana→base table; the incident file's repaired copy holds exactly zero groups. - Definitely-illegal names: past the 255-character limit, or carrying whitespace / control characters. Excel's full legacy name-character table is deliberately NOT emulated: calibration against the incident's 25k surviving names shows Excel accepts ¤, ・ and – while rejecting † and € — Windows NLS classes, not Unicode categories; a category rule would flag real books. Also whitelists the suffix-less Microsoft externalLinkPath rel-type variants (xlStartup, xlLibrary, xlAltStartup) found in the same corpus — previously 7 wrong-rel-type false positives on a file Excel opens fine (GH-529). Origin forensics (why lint, not a writer fix): the fossils pre-exist in the source books — the session's own probe of the raw input shows html/HTML/HTML all present before any xl processing, and xl's definedNames regeneration round-trips the removed records byte-identically. Verified: incident file → 3 findings, exit 1; its Excel-repaired copy → clean; the GH-525 incident file → 25 additional collision findings, all matching names Excel silently removed. Fixes GH-528. Fixes GH-529. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3bb9d70 to
596b1ad
Compare
Review:
|
| input | NFKC output |
|---|---|
| U+FB01 LATIN SMALL LIGATURE FI | fi |
| U+00B2 SUPERSCRIPT TWO | 2 |
| U+2161 ROMAN NUMERAL TWO | II |
| U+2116 NUMERO SIGN | No |
| U+2121 TELEPHONE SIGN | TEL |
So a workbook holding both Area_m2 and Area_m + U+00B2 (or a ligature spelling next to its ASCII spelling) is reported as an Excel-removal collision that Excel does not actually make. The PR's own calibration shows Excel accepting U+00A4, U+30FB and U+2013 in surviving names, so its acceptance set is clearly wider than the documented rule and superscript/ligature names are not hypothetical. A false positive at a ship gate is expensive: it blocks a deliverable that is fine.
The narrow fix keeps the corpus behaviour intact and cannot touch ASCII-only or Latin-only books: apply NFKC only to names that actually contain a compatibility-width or halfwidth-kana character, i.e. guard with s.exists(c => (c >= 0xFF01 && c <= 0xFF9F) || c == 0x3000) before normalizing (NFKC is still what composes the halfwidth voiced marks in U+FF61..U+FF9F), or fold those ranges arithmetically. Either way, a regression test pinning the ligature/ASCII pair as not a collision would lock the decision down.
3. Possible false negative: hiragana vs katakana
That U+3041 (small a) folds to U+3042 (a) is telling - small-vs-large kana is a tertiary-level difference, so Excel is almost certainly doing a Windows linguistic comparison at primary strength (the NORM_IGNORECASE | NORM_IGNOREWIDTH | NORM_IGNOREKANATYPE family). If so, hiragana equals katakana too (U+3042 == U+30A2), and the current table - which maps small kana only within each script - misses that class silently. Worth one Excel experiment: a book carrying the hiragana and the katakana spelling of the same syllable in workbook scope. If Excel repairs it, the fold needs a hiragana-to-katakana step (+0x60) as well. Not asking you to guess, just flagging that the evidence points that way, and a miss here is the same silent-removal class the PR exists to catch.
(Please do not reach for java.text.Collator at PRIMARY strength as the general fix - it folds accented Latin letters onto their base letters and would blow up Latin corpora. The explicit table is the right shape.)
4. Collision-group ordering is not fully deterministic
WorkbookLint.scala:1113 sorts by names.headOption only, over groupBy(...).toVector (unspecified Map iteration order). Two groups can share a first name - e.g. Rate/RATE at workbook scope and Rate/rate at localSheetId=0 - and then finding order falls back to hash order. Determinism is non-negotiable #3 in CLAUDE.md and lint output is consumed by tooling, so .sortBy((names, scope) => (names.headOption.getOrElse(""), scope.getOrElse(""))) (or sorting on the fold key, which is unique per group) is worth the one line.
5. One bad name can produce three findings
WorkbookLint.scala:1126-1141: problems.map { ... } emits a separate Finding per problem, so a 300-char name containing a space is 2 findings and a summary reading "3 findings" implies 3 bad names. One finding per name with problems.mkString("; ") reads truer and matches how externalRefFindings aggregates (one finding per ordinal, carrying a count).
Same block: name.take(40) truncates with no ellipsis, so the evidence for an over-long name looks like a complete name.
6. Length check counts UTF-16 code units, not characters
Option.when(name.length > maxDefinedNameLength) over-counts by one per supplementary-plane character. Not academic for this corpus specifically: CJK Extension B ideographs (U+20000+) are surrogate pairs and turn up in exactly these JP legacy books, so a 130-character name could be reported as 260. name.codePointCount(0, name.length) is a one-line fix, and it is also what "Excel's limit is 255 characters" means in the message.
7. Nameless <definedName> folds to the empty string
XmlUtil.getAttrOpt(dn, "name").getOrElse("") feeds the empty string into the collision grouping, so two <definedName> elements missing name produce 2 defined names collide under Excel's ... comparison ("", "", workbook scope), which reads as a lint bug to whoever hits it. Meanwhile a single nameless one - itself a record Excel repairs away - passes clean. Suggest filtering the empty name out of the collision path, and either flagging it as its own defined-name-invalid or noting in a comment that it is knowingly out of scope.
8. Message wording
"3 defined names collide ... Excel repairs the file by removing the named range" - Excel removes N-1 of N, so "removing all but one" is both more accurate and more actionable (the reader needs to know one name survives). Minor: sheet scope localSheetId=0 could resolve to the sheet name, which is what the user needs in order to go fix it.
9. Test coverage gaps
The 7 new tests cover the happy paths well. Missing:
- control characters - the third
illegalbranch has no test at all. - a calibration guard - a name containing the corpus-surviving symbols (U+00A4, U+30FB, U+2013) or the Excel-rejected ones (U+2020, U+20AC) must not be flagged. That deliberate non-goal is the single most likely thing for a future contributor to "fix" into existence; a negative test is what makes the scaladoc binding.
- halfwidth kana (U+FF71 vs U+30A2) - NFKC is what buys this, and it is the width case most specific to the incident corpus.
- ordering - a fixture with two collision groups, or a 3-member group, asserting exact finding order; that is what would catch XLSX Reader + OOXML Quality & Spec Compliance #4.
10. Follow-up worth an issue, not this PR
The forensics justify lint-only for the inherited case, but xl name add can itself mint a fold-duplicate: add HTML to a book already carrying the fullwidth spelling and xl will happily write a workbook Excel repairs. Catching it at the ship gate is a good backstop; refusing to create it is better.
Overall the rule is well-chosen and the false-positive discipline is right. (2) and (4) are the two I would want addressed before merge - (2) because it undercuts the PR's own calibration promise, (4) because determinism is a stated invariant - plus (1) for the client name. Everything else is polish.
CHANGELOG/STATUS/roadmap refreshed for the 2026-08-08 cut: Wave 24 (recalculation & seeding integrity) plus the late additions — the evaluator performance stack (#521/#523/#524, 7-37x) and the two lint corruption classes from this week's Excel-repair field incidents (#527/#530). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Fixes #528. Fixes #529. Stacked on #527 (same lint module) — retarget to
mainafter #527 merges.The incident (second Excel-repair corruption in two days)
A deliverable assembled from a June template carrying a 25,602-name legacy fossil corpus triggered Excel's repair dialog:
Removed Records: Named range from /xl/workbook.xml part (Workbook). Forensics (broken vs repaired diff):g/g,html/HTML,ぁ/あ. The repaired file holds exactly zero fold-duplicate groups — that is Excel's rule.HTML/HTMLacross many scopes,f/f,g/g) which Excel removed without logging them — the repair log is not a complete inventory.†,€, U+193A).Origin forensics — this is inherited, not written by us: the session's own probe of the raw input workbook shows
html+HTML+HTMLall present before any xl processing; xl's definedNames regeneration round-trips every removed record byte-identically (verified via aname addprobe); no openpyxl saves exist in either session. These are JP-Excel-97-era fossils that banker templates have carried for years — which is exactly why the lint must catch them at the ship gate (both sessions ranxl lint; it passed).The rules (calibrated false-positive-averse)
New
defined-name-invalidcategory:Deliberate non-goal: emulating Excel's full name-character table. Calibration against the 25k surviving names shows Excel accepts
¤,・,・,–while rejecting†,€— legacy Windows NLS classification, not Unicode categories. A category-based rule produces 4 false positives on this single corpus. The 6 garbage fossils therefore stay undetected in v1 (documented in code); the fold-duplicate rule alone already fails both incident files.Also fixes #529: whitelists the suffix-less Microsoft externalLinkPath rel-type variants (
xlStartup,xlLibrary,xlAltStartup) — previously 7wrong-rel-typefalse positives on externalLink parts of a file Excel opens without complaint.Verification
./mill __.test→ 1028/1028 SUCCESS🤖 Generated with Claude Code