Skip to content

Commit ce18a3c

Browse files
dwang-CXDOclaude
andcommitted
Fix "value.join is not a function": normalize the index instead of asserting it
Selecting Leia Organa, Palpatine, Qui-Gon Jinn, Darth Maul, Mace Windu, Kit Fisto, Luminara Unduli or any of the other records where `masters` or `apprentices` is a bare string took the whole page down: the detail panel called .join on a string. The source is hand-maintained community JSON and the same field is not always the same type — masters is an array on 7 records and a string on 8, apprentices likewise, homeworld is a string except Greedo's two, cybernetics is a string except Darth Maul's list, and born is a number of years except Boba Fett's "Shortly after the Battle of Naboo". The real mistake was believing a TypeScript interface. `as Character` on parsed JSON asserts a shape the compiler then stops checking and the runtime never had; it moved the lie somewhere nothing would catch it. Every record now goes through normalize(), which coerces each field to one shape — list fields are always arrays and never undefined, text fields are strings or absent, and the years arrive display-ready (BBY/ABY, or the source's own prose where it had no number). The UI stops guessing: no optional chaining on lists, no formatYear. scripts/check-index.mjs runs that normalizer over the live document and asserts the result, wired into checks.yml. It needs the network and will fail the job if the source is down — the trade for catching the next shape change in CI rather than in a browser. It passes on all 87 records today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnPtsHsXzbApqMpY6k4Pm9
1 parent 34ba31e commit ce18a3c

6 files changed

Lines changed: 309 additions & 56 deletions

File tree

‎.github/workflows/checks.yml‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,16 @@ jobs:
3737

3838
- name: Typecheck
3939
run: npm run typecheck
40+
41+
# The character index is hand-maintained community JSON whose field TYPES
42+
# are not stable — `masters` is an array on some records and a bare string
43+
# on others, which took the detail panel down with "value.join is not a
44+
# function" before the API layer started coercing them. tsc cannot catch
45+
# that: an interface is erased at build time and says nothing about what
46+
# actually arrives. So this runs the real normalizer over the live
47+
# document. It needs the network, and a source outage will fail the job —
48+
# that is the trade for catching the next shape change here rather than in
49+
# somebody's browser.
50+
- name: Character index shape
51+
if: '!cancelled()'
52+
run: npm run check:index

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@netsapiens/horizon-starwars-app",
3-
"version": "1.0.0",
3+
"version": "1.0.1",
44
"private": true,
55
"description": "Horizon federated app: browse Star Wars characters and adopt one as your avatar. A Module Federation remote loaded by the Horizon host.",
66
"author": "dwang",
@@ -9,7 +9,8 @@
99
"start": "webpack serve --mode development",
1010
"build": "webpack --mode production",
1111
"verify": "horizon-verify-bundle ./dist",
12-
"typecheck": "tsc --noEmit"
12+
"typecheck": "tsc --noEmit",
13+
"check:index": "node --experimental-strip-types scripts/check-index.mjs"
1314
},
1415
"dependencies": {
1516
"@netsapiens/horizon-sdk": "^0.2.11",

‎scripts/check-index.mjs‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* Shape test for the live character index.
3+
*
4+
* The source is hand-maintained community JSON, so a field's TYPE is not
5+
* stable: `masters` is an array on some records and a bare string on others,
6+
* `homeworld` is a string except where it is a list. A TypeScript interface
7+
* asserts nothing about that at runtime — it is erased — so this runs the real
8+
* `normalize` over the real document and checks what comes out.
9+
*
10+
* It caught nothing when it was written, because it was written after the
11+
* page had already crashed on `value.join is not a function`. It exists so
12+
* the next shape change at the source fails a build instead of a browser.
13+
*
14+
* Needs the network. Run with: npm run check:index
15+
*/
16+
import { normalize } from '../src/api/starwarsApi.ts';
17+
18+
const INDEX_URL =
19+
'https://raw.githubusercontent.com/akabab/starwars-api/master/api/all.json';
20+
21+
const LIST_FIELDS = [
22+
'affiliations',
23+
'formerAffiliations',
24+
'masters',
25+
'apprentices',
26+
'equipment',
27+
];
28+
const TEXT_FIELDS = [
29+
'name',
30+
'image',
31+
'wiki',
32+
'gender',
33+
'species',
34+
'homeworld',
35+
'born',
36+
'bornLocation',
37+
'died',
38+
'diedLocation',
39+
'cybernetics',
40+
'class',
41+
'model',
42+
'manufacturer',
43+
'haystack',
44+
];
45+
const NUMBER_FIELDS = ['id', 'height', 'mass'];
46+
47+
const response = await fetch(INDEX_URL);
48+
if (!response.ok) {
49+
console.error(`Could not fetch the index: ${response.status}`);
50+
process.exit(1);
51+
}
52+
53+
const raw = await response.json();
54+
const problems = [];
55+
56+
if (!Array.isArray(raw) || raw.length === 0) {
57+
console.error('The index is not a non-empty list');
58+
process.exit(1);
59+
}
60+
61+
const characters = raw.map(normalize).filter(Boolean);
62+
63+
for (const character of characters) {
64+
const where = `#${character.id} ${character.name}`;
65+
66+
for (const field of LIST_FIELDS) {
67+
const value = character[field];
68+
if (!Array.isArray(value)) {
69+
problems.push(`${where}: ${field} is ${typeof value}, expected an array`);
70+
} else if (value.some((entry) => typeof entry !== 'string')) {
71+
problems.push(`${where}: ${field} holds a non-string entry`);
72+
}
73+
}
74+
75+
for (const field of TEXT_FIELDS) {
76+
const value = character[field];
77+
if (value !== undefined && typeof value !== 'string') {
78+
problems.push(`${where}: ${field} is ${typeof value}, expected a string`);
79+
}
80+
}
81+
82+
for (const field of NUMBER_FIELDS) {
83+
const value = character[field];
84+
if (value !== undefined && typeof value !== 'number') {
85+
problems.push(`${where}: ${field} is ${typeof value}, expected a number`);
86+
}
87+
}
88+
}
89+
90+
const dropped = raw.length - characters.length;
91+
92+
if (problems.length) {
93+
console.error(`FAIL — ${problems.length} shape problem(s):`);
94+
problems.slice(0, 20).forEach((problem) => console.error(` ${problem}`));
95+
process.exit(1);
96+
}
97+
98+
// A source that suddenly drops most of its records still "passes" every type
99+
// check above, so assert the roster is there at all.
100+
if (characters.length < 50) {
101+
console.error(
102+
`FAIL — only ${characters.length} usable characters; the source has changed shape.`,
103+
);
104+
process.exit(1);
105+
}
106+
107+
console.log(
108+
`PASS — ${characters.length} characters normalize cleanly` +
109+
(dropped ? ` (${dropped} raw record(s) dropped as unusable)` : ''),
110+
);

0 commit comments

Comments
 (0)