@micha.bigler/ui-core-micha 3.1.0 → 3.1.1
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.
- package/CHANGELOG.md +59 -0
- package/WORK_ORDERS.md +20 -1
- package/dist/components/charts/ChartFrame.js +18 -9
- package/docs/CHART-LAYOUT.md +9 -3
- package/package.json +1 -1
- package/scripts/chart-api-census.mjs +407 -0
- package/src/components/charts/ChartFrame.jsx +19 -10
- package/tests/ChartFrame.test.jsx +30 -7
- package/tests/chart-api-census.test.js +366 -0
- package/work-orders/UCM-CHART-14.md +198 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UCM-CHART-14: a parser, not a grep. This is the whole point of this file -- five wrong consumer
|
|
3
|
+
// counts in this series (see work-orders/UCM-CHART-14.md) all came from a grep pattern that could
|
|
4
|
+
// not see JSX structure: a prop several lines below the component name, an arrow function's `=>`
|
|
5
|
+
// inside a prop value, an apostrophe inside a comment, a `>` inside a nested `{}` expression. A
|
|
6
|
+
// regex line-match cannot tell any of those apart from the thing it's looking for. This script
|
|
7
|
+
// tracks string/template/comment/regex-literal state and brace depth in ONE continuous pass over
|
|
8
|
+
// each file, so its answer is reproducible output, not a claim someone has to trust.
|
|
9
|
+
//
|
|
10
|
+
// Second review round (both `reviewer` and `ui_reviewer`, independently) found the first version's
|
|
11
|
+
// three-pass design (a raw `RegExp.exec` tag-open search, oblivious to string/comment context, then
|
|
12
|
+
// a per-tag bounds scan, then a per-tag attribute scan) could itself produce a WRONG, non-UNPARSED
|
|
13
|
+
// answer -- worse than an honest UNPARSED, because nothing flags it: JSX-looking text inside a
|
|
14
|
+
// string or comment counted as a real element, and a regex literal's `}`/`>` characters (e.g.
|
|
15
|
+
// `data={/[}>]/}`) corrupted brace-depth tracking and closed a tag early. Both are fixed here by
|
|
16
|
+
// giving every stage the SAME shared state, tracked once, forward, per file.
|
|
17
|
+
//
|
|
18
|
+
// Dev script only -- never imported by the published package (kept out of package.json's `files`/
|
|
19
|
+
// `exports`), never writes anything, and degrades to "not present" for a missing/frontend-less
|
|
20
|
+
// sibling repo rather than throwing.
|
|
21
|
+
|
|
22
|
+
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
23
|
+
import { join, relative, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
// The five props this WO's series has spent five work orders removing/relocating -- what a census
|
|
27
|
+
// exists to count is exactly "who still passes one of these".
|
|
28
|
+
export const TARGET_PROPS = ['aspect', 'height', 'minHeight', 'margin', 'xAxisAngle'];
|
|
29
|
+
export const TARGET_COMPONENTS = ['BarChart', 'LineChart', 'ScatterChart', 'TimeSeriesChart', 'ChartFrame'];
|
|
30
|
+
const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
31
|
+
const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', 'build', '.git']);
|
|
32
|
+
const PACKAGE_SPECIFIER = '@micha.bigler/ui-core-micha';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Finds every `import { ... } from '@micha.bigler/ui-core-micha'` in `source` and returns a map
|
|
36
|
+
* of LOCAL name -> CANONICAL export name, restricted to `TARGET_COMPONENTS`. Import declarations
|
|
37
|
+
* are a much simpler grammar than JSX (no nested arbitrary expressions in the specifier list), so
|
|
38
|
+
* a regex is appropriate here -- unlike the JSX scan below, which is not.
|
|
39
|
+
*/
|
|
40
|
+
function findImportedLocalNames(source) {
|
|
41
|
+
const localToCanonical = new Map();
|
|
42
|
+
const importRe = /import\s*\{([^}]*)\}\s*from\s*['"]@micha\.bigler\/ui-core-micha['"]/g;
|
|
43
|
+
let match;
|
|
44
|
+
while ((match = importRe.exec(source)) !== null) {
|
|
45
|
+
const specifiers = match[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
46
|
+
for (const spec of specifiers) {
|
|
47
|
+
const asMatch = spec.match(/^(\w+)\s+as\s+(\w+)$/);
|
|
48
|
+
const canonical = asMatch ? asMatch[1] : spec;
|
|
49
|
+
const local = asMatch ? asMatch[2] : spec;
|
|
50
|
+
if (TARGET_COMPONENTS.includes(canonical)) localToCanonical.set(local, canonical);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return localToCanonical;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lineNumberAt(source, index) {
|
|
57
|
+
let line = 1;
|
|
58
|
+
for (let i = 0; i < index; i += 1) if (source[i] === '\n') line += 1;
|
|
59
|
+
return line;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const IDENT_START = /[A-Za-z_$]/;
|
|
63
|
+
const IDENT_CHAR = /[A-Za-z0-9_$]/;
|
|
64
|
+
// Regex-literal-start heuristic: a `/` is treated as opening a regex literal (not division) only
|
|
65
|
+
// when the last significant character before it is one of these -- the common "a regex can only
|
|
66
|
+
// start where a VALUE is expected, not follow one" rule real JS tokenizers use. Not a full parser;
|
|
67
|
+
// documented, and sufficient for `data={/[}>]/}`-shaped prop values, the concrete case found in
|
|
68
|
+
// review. A `/` following an identifier/number/`)`/`]` is treated as division and left alone.
|
|
69
|
+
//
|
|
70
|
+
// Regression found DURING this same fix's own verification run (before it ever reached review):
|
|
71
|
+
// `<` and `>` were originally in this set on the theory that `/` can start a regex right after a
|
|
72
|
+
// comparison operator -- but `<`/`>` are also exactly what precede the `/` in EVERY JSX closing
|
|
73
|
+
// tag (`</Something>`) and self-closing tag (`Something />`). With them included, `</ChartFrame>`
|
|
74
|
+
// itself was misread as a regex-literal start, consuming everything up to the next stray `/` in
|
|
75
|
+
// the file and corrupting brace depth for the rest of the scan -- turning every multi-line
|
|
76
|
+
// ChartFrame usage in the estate into a false UNPARSED. A `/` genuinely starting a regex right
|
|
77
|
+
// after `<`/`>` (e.g. `a < /regex/.test(b)`) does not occur in practice in this codebase; dropped.
|
|
78
|
+
const REGEX_PRECEDING_CHARS = new Set(['(', '{', '[', ',', ';', ':', '=', '&', '|', '!', '?', '+', '-', '*', '%', '^', '~']);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extracts top-level (tag-depth-0, RELATIVE TO `tagSource`) attribute names from `tagSource` (a
|
|
82
|
+
* full, already-correctly-bounded `<Name ...>` span). Shares the same string/template/comment/
|
|
83
|
+
* regex tracking as the outer scanner, so a prop value containing a regex literal, a nested
|
|
84
|
+
* object, or a comment can never be mistaken for a top-level attribute name -- `sx={{ aspect: 1
|
|
85
|
+
* }}` never reports a false `aspect` (an object key inside an expression, not a JSX attribute),
|
|
86
|
+
* and `data={/[}>]/}` never corrupts this function's own depth count either.
|
|
87
|
+
*/
|
|
88
|
+
function extractTopLevelAttributeNames(tagSource) {
|
|
89
|
+
const found = new Set();
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let inString = null;
|
|
92
|
+
let inLineComment = false;
|
|
93
|
+
let inBlockComment = false;
|
|
94
|
+
let inRegex = false;
|
|
95
|
+
let inRegexClass = false;
|
|
96
|
+
let lastSignificant = null;
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < tagSource.length; i += 1) {
|
|
99
|
+
const c = tagSource[i];
|
|
100
|
+
const next = tagSource[i + 1];
|
|
101
|
+
|
|
102
|
+
if (inLineComment) { if (c === '\n') inLineComment = false; continue; }
|
|
103
|
+
if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i += 1; } continue; }
|
|
104
|
+
if (inRegex) {
|
|
105
|
+
if (c === '\\') { i += 1; continue; }
|
|
106
|
+
if (c === '[') { inRegexClass = true; continue; }
|
|
107
|
+
if (c === ']') { inRegexClass = false; continue; }
|
|
108
|
+
if (c === '/' && !inRegexClass) {
|
|
109
|
+
inRegex = false;
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
while (j < tagSource.length && /[a-z]/i.test(tagSource[j])) j += 1;
|
|
112
|
+
i = j - 1;
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (inString) {
|
|
117
|
+
if (c === '\\') { i += 1; continue; }
|
|
118
|
+
if (c === inString) inString = null;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (c === '/' && next === '/') { inLineComment = true; i += 1; continue; }
|
|
122
|
+
if (c === '/' && next === '*') { inBlockComment = true; i += 1; continue; }
|
|
123
|
+
if (c === '/' && (lastSignificant === null || REGEX_PRECEDING_CHARS.has(lastSignificant))) {
|
|
124
|
+
inRegex = true; inRegexClass = false; lastSignificant = '/'; continue;
|
|
125
|
+
}
|
|
126
|
+
if (c === '"' || c === "'" || c === '`') { inString = c; lastSignificant = c; continue; }
|
|
127
|
+
if (c === '{') { depth += 1; lastSignificant = c; continue; }
|
|
128
|
+
if (c === '}') { depth = Math.max(0, depth - 1); lastSignificant = c; continue; }
|
|
129
|
+
|
|
130
|
+
if (depth === 0 && IDENT_START.test(c) && (i === 0 || !IDENT_CHAR.test(tagSource[i - 1]))) {
|
|
131
|
+
let j = i + 1;
|
|
132
|
+
while (j < tagSource.length && IDENT_CHAR.test(tagSource[j])) j += 1;
|
|
133
|
+
found.add(tagSource.slice(i, j));
|
|
134
|
+
lastSignificant = tagSource[j - 1];
|
|
135
|
+
i = j - 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!/\s/.test(c)) lastSignificant = c;
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The single-pass scanner. Walks `source` exactly ONCE, maintaining ONE shared state for strings,
|
|
145
|
+
* template literals (+ `${}` expression nesting), line/block comments, regex literals, and brace
|
|
146
|
+
* depth -- so tag-open detection and tag-close detection agree on where the real code is, instead
|
|
147
|
+
* of a separate context-blind pass for each. A `<Name` is only recognised as a real JSX tag open
|
|
148
|
+
* when the scanner is not currently inside a string/comment/regex, which is what stops `// <BarChart
|
|
149
|
+
* aspect />` or `"<BarChart aspect />"` from being counted. Multiple target tags can be pending at
|
|
150
|
+
* once (a component nested inside another's own prop expression, e.g. `<ChartFrame content={<>
|
|
151
|
+
* <BarChart aspect /></>} />`) -- each is closed independently, at the exact brace depth it opened
|
|
152
|
+
* at, so a nested tag closing does not consume or corrupt the outer tag's own remaining scan.
|
|
153
|
+
*
|
|
154
|
+
* A tag still open when the file ends is reported UNPARSED, never dropped (Definition of Done).
|
|
155
|
+
*/
|
|
156
|
+
function scanForComponents(source, localToCanonical) {
|
|
157
|
+
const results = [];
|
|
158
|
+
const openTags = []; // { canonical, tagStart, closeAtDepth, line }
|
|
159
|
+
const localNames = [...localToCanonical.keys()];
|
|
160
|
+
|
|
161
|
+
let inString = null;
|
|
162
|
+
let inLineComment = false;
|
|
163
|
+
let inBlockComment = false;
|
|
164
|
+
let inRegex = false;
|
|
165
|
+
let inRegexClass = false;
|
|
166
|
+
let depth = 0;
|
|
167
|
+
const templateDepthStack = [];
|
|
168
|
+
let lastSignificant = null;
|
|
169
|
+
|
|
170
|
+
const closePendingAtCurrentDepth = (closeIndex) => {
|
|
171
|
+
for (let k = openTags.length - 1; k >= 0; k -= 1) {
|
|
172
|
+
if (openTags[k].closeAtDepth === depth) {
|
|
173
|
+
const tag = openTags.splice(k, 1)[0];
|
|
174
|
+
const tagSource = source.slice(tag.tagStart, closeIndex + 1);
|
|
175
|
+
const attrNames = extractTopLevelAttributeNames(tagSource);
|
|
176
|
+
const props = {};
|
|
177
|
+
for (const prop of TARGET_PROPS) props[prop] = attrNames.has(prop);
|
|
178
|
+
results.push({ component: tag.canonical, line: tag.line, unparsed: false, props });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
184
|
+
const c = source[i];
|
|
185
|
+
const next = source[i + 1];
|
|
186
|
+
|
|
187
|
+
if (inLineComment) { if (c === '\n') inLineComment = false; continue; }
|
|
188
|
+
if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i += 1; } continue; }
|
|
189
|
+
if (inRegex) {
|
|
190
|
+
if (c === '\\') { i += 1; continue; }
|
|
191
|
+
if (c === '[') { inRegexClass = true; continue; }
|
|
192
|
+
if (c === ']') { inRegexClass = false; continue; }
|
|
193
|
+
if (c === '/' && !inRegexClass) {
|
|
194
|
+
inRegex = false;
|
|
195
|
+
let j = i + 1;
|
|
196
|
+
while (j < source.length && /[a-z]/i.test(source[j])) j += 1;
|
|
197
|
+
i = j - 1;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (inString) {
|
|
202
|
+
if (c === '\\') { i += 1; continue; }
|
|
203
|
+
if (inString === '`' && c === '$' && next === '{') {
|
|
204
|
+
templateDepthStack.push(depth);
|
|
205
|
+
depth += 1;
|
|
206
|
+
i += 1;
|
|
207
|
+
inString = null;
|
|
208
|
+
lastSignificant = '{';
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (c === inString) inString = null;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (c === '/' && next === '/') { inLineComment = true; i += 1; continue; }
|
|
216
|
+
if (c === '/' && next === '*') { inBlockComment = true; i += 1; continue; }
|
|
217
|
+
if (c === '/' && (lastSignificant === null || REGEX_PRECEDING_CHARS.has(lastSignificant))) {
|
|
218
|
+
inRegex = true; inRegexClass = false; lastSignificant = '/'; continue;
|
|
219
|
+
}
|
|
220
|
+
if (c === '"' || c === "'" || c === '`') { inString = c; lastSignificant = c; continue; }
|
|
221
|
+
|
|
222
|
+
if (c === '{') { depth += 1; lastSignificant = c; continue; }
|
|
223
|
+
if (c === '}') {
|
|
224
|
+
if (templateDepthStack.length && depth - 1 === templateDepthStack[templateDepthStack.length - 1]) {
|
|
225
|
+
templateDepthStack.pop();
|
|
226
|
+
depth -= 1;
|
|
227
|
+
inString = '`';
|
|
228
|
+
lastSignificant = '}';
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
depth = Math.max(0, depth - 1);
|
|
232
|
+
lastSignificant = c;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (c === '<') {
|
|
237
|
+
let matchedName = null;
|
|
238
|
+
for (const name of localNames) {
|
|
239
|
+
if (source.startsWith(name, i + 1)) {
|
|
240
|
+
const after = source[i + 1 + name.length];
|
|
241
|
+
if (after === undefined || /[\s/>]/.test(after)) { matchedName = name; break; }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (matchedName) {
|
|
245
|
+
openTags.push({
|
|
246
|
+
canonical: localToCanonical.get(matchedName),
|
|
247
|
+
tagStart: i,
|
|
248
|
+
closeAtDepth: depth,
|
|
249
|
+
line: lineNumberAt(source, i),
|
|
250
|
+
});
|
|
251
|
+
i += matchedName.length;
|
|
252
|
+
lastSignificant = matchedName[matchedName.length - 1];
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
lastSignificant = c;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (c === '>') {
|
|
260
|
+
closePendingAtCurrentDepth(i);
|
|
261
|
+
lastSignificant = c;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!/\s/.test(c)) lastSignificant = c;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const tag of openTags) {
|
|
269
|
+
results.push({ component: tag.canonical, line: tag.line, unparsed: true, props: {} });
|
|
270
|
+
}
|
|
271
|
+
return results;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The pure analysis function -- no filesystem access, so this is what the fixture tests below
|
|
276
|
+
* exercise directly. Returns one entry per JSX element found whose tag name resolves (via the
|
|
277
|
+
* file's own imports) to one of `TARGET_COMPONENTS`.
|
|
278
|
+
*
|
|
279
|
+
* Documented, accepted heuristic limitation (unchanged from the first version, not solved by the
|
|
280
|
+
* single-pass rewrite): `<` is only ever treated as a JSX tag open, never as a less-than
|
|
281
|
+
* comparison against an uppercase-starting identifier (`foo < BarChart`). Real component
|
|
282
|
+
* references are never used in a numeric/relational comparison in practice, so this is judged a
|
|
283
|
+
* theoretical gap, not a practical one -- unlike the string/comment/regex-literal blindness fixed
|
|
284
|
+
* above, which were concrete, plausible false positives.
|
|
285
|
+
*/
|
|
286
|
+
export function findChartApiUsage(source) {
|
|
287
|
+
const localToCanonical = findImportedLocalNames(source);
|
|
288
|
+
if (localToCanonical.size === 0) return [];
|
|
289
|
+
return scanForComponents(source, localToCanonical);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function walkSourceFiles(rootDir) {
|
|
293
|
+
const files = [];
|
|
294
|
+
function walk(dir) {
|
|
295
|
+
let entries;
|
|
296
|
+
try {
|
|
297
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
for (const entry of entries) {
|
|
302
|
+
if (entry.name.startsWith('.')) continue;
|
|
303
|
+
if (SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
304
|
+
const full = join(dir, entry.name);
|
|
305
|
+
if (entry.isDirectory()) {
|
|
306
|
+
walk(full);
|
|
307
|
+
} else if (entry.isFile()) {
|
|
308
|
+
const dot = entry.name.lastIndexOf('.');
|
|
309
|
+
if (dot === -1) continue;
|
|
310
|
+
if (SOURCE_EXTENSIONS.has(entry.name.slice(dot))) files.push(full);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
walk(rootDir);
|
|
315
|
+
return files;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Locates an app's frontend source root: `frontend/src` first (this estate's usual layout), then
|
|
320
|
+
* a bare `src/` fallback. Returns `null` (never throws) if neither exists -- a repo with no
|
|
321
|
+
* frontend, or absent entirely, is reported as "not present", not an error.
|
|
322
|
+
*/
|
|
323
|
+
function findAppSourceRoot(appDir) {
|
|
324
|
+
const candidates = [join(appDir, 'frontend', 'src'), join(appDir, 'src')];
|
|
325
|
+
for (const candidate of candidates) {
|
|
326
|
+
if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate;
|
|
327
|
+
}
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function runCensus(workspaceRoot, { excludeDirs = ['ui-core-micha'] } = {}) {
|
|
332
|
+
let appNames;
|
|
333
|
+
try {
|
|
334
|
+
appNames = readdirSync(workspaceRoot, { withFileTypes: true })
|
|
335
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !excludeDirs.includes(e.name))
|
|
336
|
+
.map((e) => e.name);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
return { workspaceRoot, apps: [], error: `workspace root unreadable: ${err.message}` };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const apps = appNames.map((appName) => {
|
|
342
|
+
const appDir = join(workspaceRoot, appName);
|
|
343
|
+
const sourceRoot = findAppSourceRoot(appDir);
|
|
344
|
+
if (!sourceRoot) return { app: appName, present: false, entries: [] };
|
|
345
|
+
|
|
346
|
+
const entries = [];
|
|
347
|
+
for (const file of walkSourceFiles(sourceRoot)) {
|
|
348
|
+
let source;
|
|
349
|
+
try {
|
|
350
|
+
source = readFileSync(file, 'utf8');
|
|
351
|
+
} catch {
|
|
352
|
+
continue; // unreadable file (permissions, symlink race) -- not a parse failure, skip quietly
|
|
353
|
+
}
|
|
354
|
+
if (!source.includes(PACKAGE_SPECIFIER)) continue; // fast path: no ucm import at all
|
|
355
|
+
const usages = findChartApiUsage(source);
|
|
356
|
+
for (const usage of usages) {
|
|
357
|
+
entries.push({ ...usage, file: relative(workspaceRoot, file).replace(/\\/g, '/') });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { app: appName, present: true, entries };
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return { workspaceRoot, apps };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Reports EVERY element found (Definition of Done: "every `<ChartFrame>` and every ucm chart
|
|
368
|
+
* preset element"), not just the ones carrying a target prop -- a clean element is printed as
|
|
369
|
+
* `(clean)`, not omitted, so "this app has zero matching elements at all" and "this app has
|
|
370
|
+
* elements, all clean" stay visibly distinct instead of looking identical.
|
|
371
|
+
*/
|
|
372
|
+
function formatReport(result) {
|
|
373
|
+
const lines = [];
|
|
374
|
+
let totalUnparsed = 0;
|
|
375
|
+
for (const { app, present, entries } of result.apps) {
|
|
376
|
+
if (!present) { lines.push(`${app}: not present (no frontend/src or src)`); continue; }
|
|
377
|
+
if (entries.length === 0) continue;
|
|
378
|
+
lines.push(`${app}:`);
|
|
379
|
+
for (const entry of entries) {
|
|
380
|
+
if (entry.unparsed) {
|
|
381
|
+
totalUnparsed += 1;
|
|
382
|
+
lines.push(` UNPARSED ${entry.file}:${entry.line} <${entry.component}> -- could not close this tag, not counted as clean`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const activeProps = TARGET_PROPS.filter((p) => entry.props[p]);
|
|
386
|
+
const propsText = activeProps.length > 0 ? activeProps.join(' ') : '(clean)';
|
|
387
|
+
lines.push(` ${entry.file}:${entry.line} <${entry.component} ${propsText}>`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (totalUnparsed > 0) {
|
|
391
|
+
lines.push('');
|
|
392
|
+
lines.push(`${totalUnparsed} tag(s) could not be parsed -- see UNPARSED lines above. Not counted as clean.`);
|
|
393
|
+
}
|
|
394
|
+
return lines.join('\n');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// `pathToFileURL` (not manual string-building) is what makes this comparison correct on Windows,
|
|
398
|
+
// where a bare `file://${path}` is missing the extra slash before the drive letter that
|
|
399
|
+
// `import.meta.url` actually has (`file:///C:/...`) -- a naive comparison silently never matches,
|
|
400
|
+
// so `node scripts/chart-api-census.mjs` would run to completion with zero output and no error.
|
|
401
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
402
|
+
if (isMain) {
|
|
403
|
+
const workspaceRoot = process.argv[2] || join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
404
|
+
const result = runCensus(workspaceRoot);
|
|
405
|
+
// eslint-disable-next-line no-console
|
|
406
|
+
console.log(formatReport(result));
|
|
407
|
+
}
|
|
@@ -24,14 +24,20 @@ export const CHART_FRAME_ALERT_SX = { mt: 2 };
|
|
|
24
24
|
*
|
|
25
25
|
* UCM-CHART-13: `height` and `aspect` are GONE. `height` was destructured and only ever fed a
|
|
26
26
|
* dev-mode mismatch warning, never applied to the box -- genuinely dead. `aspect` is a DIFFERENT
|
|
27
|
-
* case
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
27
|
+
* case: it WAS wired to `aspectRatio` on the box at 3.0.1, and worked.
|
|
28
|
+
*
|
|
29
|
+
* UCM-CHART-14 CORRECTION -- left legible on purpose, not deleted: `UCM-CHART-13` shipped claiming
|
|
30
|
+
* "no consumer across the five apps passes [aspect] (measured against 3.0.1)". **That measurement
|
|
31
|
+
* was false.** A parser-based census (`scripts/chart-api-census.mjs`, ground truth over grep) found
|
|
32
|
+
* FOUR live call sites in fitness-monitor (`BodyHistoryPage.jsx:295,444`, `EnvironmentPage.jsx:245,279`)
|
|
33
|
+
* that a `head`-truncated grep had silently missed -- the sixth wrong consumer count in this series
|
|
34
|
+
* (see `UCM-CHART-14`'s own WO for the other five). The REMOVAL itself still stands (`aspect` stays
|
|
35
|
+
* gone, the operator confirmed this is not being reopened) -- only the record of who it affected was
|
|
36
|
+
* wrong, and is corrected here rather than quietly rewritten. Those four sites are `FM-CHART-1`'s
|
|
37
|
+
* scope, not this file's. Passing either prop now throws in dev via the same `assertRemovedChartProp`
|
|
38
|
+
* the four chart presets use (`UCM-CHART-12`), naming `minHeight` (a card floor) or the chart's own
|
|
39
|
+
* `size` prop as the replacement -- `aspect`'s own message states plainly that it WAS applied and
|
|
40
|
+
* this is a real behaviour change (`UCM-CHART-14`, F2: the message previously said the opposite).
|
|
35
41
|
*/
|
|
36
42
|
export function ChartFrame({
|
|
37
43
|
title,
|
|
@@ -76,8 +82,11 @@ export function ChartFrame({
|
|
|
76
82
|
);
|
|
77
83
|
assertRemovedChartProp(
|
|
78
84
|
'ChartFrame', 'aspect', legacyProps.aspect,
|
|
79
|
-
'
|
|
80
|
-
'
|
|
85
|
+
'It WAS applied here (aspectRatio on the card box, through 3.0.1) -- this is a real behaviour '
|
|
86
|
+
+ 'change, not a no-op removal. The card\'s height now follows its content, with minHeight as '
|
|
87
|
+
+ 'the floor. If you wanted the chart itself taller or shorter, set size on the chart inside '
|
|
88
|
+
+ 'the frame.',
|
|
89
|
+
'UCM-CHART-14, v3.1.1',
|
|
81
90
|
);
|
|
82
91
|
|
|
83
92
|
const runExport = async (type) => {
|
|
@@ -251,22 +251,45 @@ describe('ChartFrame', () => {
|
|
|
251
251
|
});
|
|
252
252
|
});
|
|
253
253
|
|
|
254
|
-
// UCM-CHART-13: `height
|
|
255
|
-
//
|
|
256
|
-
// the
|
|
257
|
-
|
|
254
|
+
// UCM-CHART-13: `height` was destructured but never applied to the box -- genuinely dead, no
|
|
255
|
+
// consumer lost anything when it went. `aspect` is DIFFERENT: it WAS applied (`aspectRatio` on
|
|
256
|
+
// the box, through 3.0.1). UCM-CHART-13 shipped claiming no consumer passed it either -- that was
|
|
257
|
+
// false (a head-truncated grep missed four live call sites in fitness-monitor); UCM-CHART-14
|
|
258
|
+
// corrects the record and rewrites `aspect`'s own error message, which previously said "never
|
|
259
|
+
// applied" -- the opposite of what happened, and actively misleading for the four callers who
|
|
260
|
+
// will actually see it. Both props are gone; passing either throws in dev, naming the real
|
|
261
|
+
// replacement, the same mechanism the four chart presets use (`UCM-CHART-12`).
|
|
262
|
+
describe('removed height/aspect props throw in dev, naming their replacement (UCM-CHART-13/14)', () => {
|
|
258
263
|
it('throws when a caller still passes height, naming minHeight AND size as the replacement', () => {
|
|
259
264
|
expect(() => renderFrame({ height: 380 })).toThrow(/ChartFrame.*height.*minHeight.*size/s);
|
|
260
265
|
});
|
|
261
266
|
|
|
262
|
-
it('throws when a caller still passes aspect, naming size as the replacement', () => {
|
|
263
|
-
|
|
267
|
+
it('throws when a caller still passes aspect, naming size as the replacement -- WITHOUT claiming it was never applied', () => {
|
|
268
|
+
const message = (() => {
|
|
269
|
+
try { renderFrame({ aspect: 1.8 }); } catch (err) { return err.message; }
|
|
270
|
+
throw new Error('expected renderFrame to throw');
|
|
271
|
+
})();
|
|
272
|
+
expect(message).toMatch(/ChartFrame.*aspect.*size/s);
|
|
273
|
+
// ui_reviewer finding: the prior version of this test only checked /size/, which a message
|
|
274
|
+
// naming JUST size (and dropping minHeight) would still pass -- the card's own floor is
|
|
275
|
+
// still minHeight, so the message must say so too, not only point at the chart's `size`.
|
|
276
|
+
expect(message).toMatch(/minHeight/);
|
|
277
|
+
// UCM-CHART-14 F2: the message must not repeat the false "never applied" claim that shipped
|
|
278
|
+
// in UCM-CHART-13 -- aspect DID apply aspectRatio to the box, and removing it is a real,
|
|
279
|
+
// visible change for the four callers who will hit this. A test only matching /size/ would
|
|
280
|
+
// pass on either wording, which is exactly how the false message shipped unnoticed.
|
|
281
|
+
expect(message).not.toMatch(/never applied/i);
|
|
282
|
+
expect(message).toMatch(/was applied/i);
|
|
264
283
|
});
|
|
265
284
|
|
|
266
|
-
it('attributes the removal to UCM-CHART-13/v3.1.0
|
|
285
|
+
it('attributes the height removal to UCM-CHART-13/v3.1.0 (unchanged -- height\'s message was already true)', () => {
|
|
267
286
|
expect(() => renderFrame({ height: 380 })).toThrow(/UCM-CHART-13.*v3\.1\.0/);
|
|
268
287
|
});
|
|
269
288
|
|
|
289
|
+
it('attributes the aspect removal to UCM-CHART-14/v3.1.1 (the corrected message, not the original false one)', () => {
|
|
290
|
+
expect(() => renderFrame({ aspect: 1.8 })).toThrow(/UCM-CHART-14.*v3\.1\.1/);
|
|
291
|
+
});
|
|
292
|
+
|
|
270
293
|
it('does not throw when only minHeight is passed', () => {
|
|
271
294
|
expect(() => renderFrame({ minHeight: 320 })).not.toThrow();
|
|
272
295
|
});
|