@godxjp/ui 23.2.0 → 23.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "23.2.0",
4
- "godxUiMcp": "23.2.0",
3
+ "version": "23.2.1",
4
+ "godxUiMcp": "23.2.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -3,10 +3,16 @@
3
3
  * init-agent-kit.mjs (explicit, full kit). Every writer is IDEMPOTENT and
4
4
  * NON-DESTRUCTIVE: it only creates a missing file or ADDS a missing key, never
5
5
  * overwrites existing config.
6
+ *
7
+ * That sentence was a claim, not a guarantee, until gh#541: `readJson` returned `null` for BOTH
8
+ * "no file" and "file I cannot parse", so `readJson(path) ?? {}` read a consumer's malformed
9
+ * `.mcp.json` as an empty one and wrote over it — their other MCP servers went with it, silently.
10
+ * The guarantee is now structural: a file that exists but cannot be read, parsed, or recognised is
11
+ * NEVER written to. We leave a `.godxjp-ui-suggested` sidecar next to it and say so.
6
12
  */
7
13
  import { createHash } from "node:crypto";
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
- import { dirname, join } from "node:path";
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { basename, dirname, join } from "node:path";
10
16
  import { fileURLToPath } from "node:url";
11
17
 
12
18
  /** The godxjp-ui MCP server — pulled on demand via npx (no extra dependency to ship). */
@@ -20,6 +26,14 @@ export const MCP_KEY = "godx-ui";
20
26
  export const AUDIT_HOOK_CMD = "node node_modules/@godxjp/ui/scripts/audit-hook.mjs";
21
27
  export const PRIMER_CMD = "cat .claude/godxjp-ui-workflow.md";
22
28
 
29
+ /** What `.claude/settings.json` would get, used only for the `.godxjp-ui-suggested` sidecar. */
30
+ const SUGGESTED_HOOKS = {
31
+ PostToolUse: [
32
+ { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: AUDIT_HOOK_CMD }] },
33
+ ],
34
+ SessionStart: [{ hooks: [{ type: "command", command: PRIMER_CMD }] }],
35
+ };
36
+
23
37
  /** The per-session workflow mandate the SessionStart hook injects into the agent. */
24
38
  export const KIT_VERSION = readJson(join(SELF_ROOT, "package.json"))?.version ?? "0.0.0";
25
39
 
@@ -145,12 +159,65 @@ Full guide: \`.claude/godxjp-ui-workflow.md\`.
145
159
  <!-- godxjp-ui:end -->
146
160
  `;
147
161
 
148
- function readJson(path) {
162
+ /**
163
+ * Read a JSON file and say WHICH failure happened. Collapsing "absent" and "corrupt" into one
164
+ * `null` is the whole of gh#541 — see the module header.
165
+ *
166
+ * @returns {{state:"ok",json:object}|{state:"missing"}|{state:"unreadable"}|{state:"invalid-json"}|{state:"wrong-shape"}}
167
+ */
168
+ function readJsonFile(path) {
169
+ let raw;
170
+ try {
171
+ raw = readFileSync(path, "utf8");
172
+ } catch (error) {
173
+ return error.code === "ENOENT" ? { state: "missing" } : { state: "unreadable" };
174
+ }
175
+ let json;
149
176
  try {
150
- return JSON.parse(readFileSync(path, "utf8"));
177
+ json = JSON.parse(raw);
151
178
  } catch {
152
- return null;
179
+ return { state: "invalid-json" };
153
180
  }
181
+ // `JSON.parse` happily returns `null`, `[]` or `"text"`. Merging keys into any of those and
182
+ // writing the result back is the same data loss by a different door, so it is a refusal too.
183
+ if (json === null || typeof json !== "object" || Array.isArray(json))
184
+ return { state: "wrong-shape" };
185
+ return { state: "ok", json };
186
+ }
187
+
188
+ /** The sentence that goes in the refusal, so a consumer knows which of the three it hit. */
189
+ const READ_FAILURE = {
190
+ "invalid-json": "not valid JSON",
191
+ "wrong-shape": "JSON, but not an object",
192
+ unreadable: "unreadable",
193
+ };
194
+
195
+ /** Unusable-as-absent — only for the read-only callers, which never write anything back. */
196
+ function readJson(path) {
197
+ const read = readJsonFile(path);
198
+ return read.state === "ok" ? read.json : null;
199
+ }
200
+
201
+ /**
202
+ * Write through a temp file + rename. `writeFileSync` is NOT atomic: an interrupt mid-write (^C
203
+ * during `npm install`, a full disk, the OOM killer) leaves a truncated file behind, which the
204
+ * next run reads as malformed. That is how the two halves of gh#541 fed each other — one bad
205
+ * write manufactured the precondition for the overwrite.
206
+ */
207
+ function writeFileAtomic(path, data) {
208
+ const tmp = `${path}.godxjp-ui-tmp`;
209
+ writeFileSync(tmp, data);
210
+ renameSync(tmp, path);
211
+ }
212
+
213
+ /**
214
+ * Refuse to touch `path`, leave what we WOULD have written beside it, and return the sentence the
215
+ * caller prints. A diff the consumer can read beats a config we guessed at.
216
+ */
217
+ function refuseAndSuggest(path, data, reason) {
218
+ const suggested = `${path}.godxjp-ui-suggested`;
219
+ writeFileAtomic(suggested, data);
220
+ return `left untouched (${reason}) — see ${basename(suggested)}`;
154
221
  }
155
222
 
156
223
  /** Ensure `.mcp.json` registers the godx-ui MCP server. Returns 'created' | 'added' | 'present'. */
@@ -164,22 +231,61 @@ function readJson(path) {
164
231
  * agent reading whatever guidance shipped the day the package was FIRST installed. The library
165
232
  * moved; the instructions for using it did not.
166
233
  */
234
+ /**
235
+ * Replace the delimited managed region, or REFUSE.
236
+ *
237
+ * The old shape set `tail = ""` when the closing marker was missing and returned
238
+ * `current.slice(0, i) + next` — so a file whose end marker had been deleted (or truncated away by
239
+ * a half-finished write) lost everything after the opening marker. Whatever the consumer wrote
240
+ * below our block was simply gone (gh#541).
241
+ *
242
+ * There is no safe way to guess where a region ends, so we do not guess. Two refusals, both
243
+ * returning `null` for the caller to report:
244
+ * • the opening marker appears more than once — which region is ours is ambiguous;
245
+ * • the closing marker is missing — the region has no end.
246
+ * A closing marker that appears more than once is NOT a refusal: the first one after the opening
247
+ * marker is the region's end, and everything past it is preserved either way.
248
+ *
249
+ * @returns {string|null} the rewritten file, or `null` to say "do not write".
250
+ */
167
251
  export function refreshBlock(current, next, startMarker, endMarker) {
168
- const i = current.indexOf(startMarker);
169
- if (i < 0) return current.replace(/\s*$/, "") + "\n\n" + next;
170
- const j = endMarker ? current.indexOf(endMarker, i) : -1;
171
- const tail = j < 0 ? "" : current.slice(j + endMarker.length);
172
- return current.slice(0, i) + next + tail;
252
+ const first = current.indexOf(startMarker);
253
+ if (first < 0) return current.replace(/\s*$/, "") + "\n\n" + next;
254
+ if (current.indexOf(startMarker, first + startMarker.length) >= 0) return null;
255
+ if (!endMarker) return null;
256
+ const j = current.indexOf(endMarker, first);
257
+ if (j < 0) return null;
258
+ return current.slice(0, first) + next + current.slice(j + endMarker.length);
173
259
  }
174
260
 
175
261
  export function ensureMcpJson(root) {
176
262
  const path = join(root, ".mcp.json");
177
- const json = readJson(path) ?? {};
263
+ const read = readJsonFile(path);
264
+ if (read.state !== "ok" && read.state !== "missing") {
265
+ return refuseAndSuggest(
266
+ path,
267
+ JSON.stringify({ mcpServers: { [MCP_KEY]: MCP_SERVER } }, null, 2) + "\n",
268
+ READ_FAILURE[read.state],
269
+ );
270
+ }
271
+ const json = read.state === "ok" ? read.json : {};
272
+ if (
273
+ json.mcpServers !== undefined &&
274
+ (json.mcpServers === null ||
275
+ typeof json.mcpServers !== "object" ||
276
+ Array.isArray(json.mcpServers))
277
+ ) {
278
+ return refuseAndSuggest(
279
+ path,
280
+ JSON.stringify({ mcpServers: { [MCP_KEY]: MCP_SERVER } }, null, 2) + "\n",
281
+ "`mcpServers` is not an object",
282
+ );
283
+ }
178
284
  json.mcpServers = json.mcpServers ?? {};
179
285
  if (json.mcpServers[MCP_KEY]) return "present";
180
- const created = !existsSync(path);
286
+ const created = read.state === "missing";
181
287
  json.mcpServers[MCP_KEY] = MCP_SERVER;
182
- writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
288
+ writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
183
289
  return created ? "created" : "added";
184
290
  }
185
291
 
@@ -187,7 +293,31 @@ export function ensureMcpJson(root) {
187
293
  export function ensureClaudeHooks(root) {
188
294
  const path = join(root, ".claude", "settings.json");
189
295
  mkdirSync(dirname(path), { recursive: true });
190
- const json = readJson(path) ?? {};
296
+ const read = readJsonFile(path);
297
+ if (read.state !== "ok" && read.state !== "missing") {
298
+ // Same guard as ensureMcpJson, and the reason it is here rather than only there: this file
299
+ // holds the consumer's OWN hooks. Rewriting it from `{}` silently unhooks their tooling.
300
+ return [
301
+ refuseAndSuggest(
302
+ path,
303
+ JSON.stringify({ hooks: SUGGESTED_HOOKS }, null, 2) + "\n",
304
+ READ_FAILURE[read.state],
305
+ ),
306
+ ];
307
+ }
308
+ const json = read.state === "ok" ? read.json : {};
309
+ if (
310
+ json.hooks !== undefined &&
311
+ (json.hooks === null || typeof json.hooks !== "object" || Array.isArray(json.hooks))
312
+ ) {
313
+ return [
314
+ refuseAndSuggest(
315
+ path,
316
+ JSON.stringify({ hooks: SUGGESTED_HOOKS }, null, 2) + "\n",
317
+ "`hooks` is not an object",
318
+ ),
319
+ ];
320
+ }
191
321
  json.hooks = json.hooks ?? {};
192
322
  const added = [];
193
323
 
@@ -209,7 +339,7 @@ export function ensureClaudeHooks(root) {
209
339
  added.push("SessionStart:workflow-primer");
210
340
  }
211
341
 
212
- writeFileSync(path, JSON.stringify(json, null, 2) + "\n");
342
+ writeFileAtomic(path, JSON.stringify(json, null, 2) + "\n");
213
343
  return added;
214
344
  }
215
345
 
@@ -225,10 +355,10 @@ export function writeWorkflowMd(root) {
225
355
  if (existsSync(path)) {
226
356
  const cur = readFileSync(path, "utf8");
227
357
  if (cur.trim() === WORKFLOW_MD.trim()) return false;
228
- writeFileSync(path, WORKFLOW_MD);
358
+ writeFileAtomic(path, WORKFLOW_MD);
229
359
  return "refreshed";
230
360
  }
231
- writeFileSync(path, WORKFLOW_MD);
361
+ writeFileAtomic(path, WORKFLOW_MD);
232
362
  return true;
233
363
  }
234
364
 
@@ -248,7 +378,8 @@ function blockIsCurrent(existing, block) {
248
378
  const wanted = stampedDigest(block);
249
379
  const have = stampedDigest(existing);
250
380
  if (wanted && have) return wanted === have;
251
- const region = (text) => text.slice(text.indexOf("<!-- godxjp-ui:start"), text.indexOf("<!-- godxjp-ui:end -->"));
381
+ const region = (text) =>
382
+ text.slice(text.indexOf("<!-- godxjp-ui:start"), text.indexOf("<!-- godxjp-ui:end -->"));
252
383
  return region(existing).trim() === region(block).trim();
253
384
  }
254
385
 
@@ -264,17 +395,22 @@ export function ensureClaudeMd(root) {
264
395
  // reached nobody, and the block still read as current. A file written before digest stamping
265
396
  // has no digest at all, so fall back to comparing the rendered body.
266
397
  if (blockIsCurrent(existing, CLAUDE_MD_BLOCK)) return "present";
267
- writeFileSync(
268
- path,
269
- refreshBlock(existing, CLAUDE_MD_BLOCK, "<!-- godxjp-ui:start", "<!-- godxjp-ui:end -->"),
398
+ const next = refreshBlock(
399
+ existing,
400
+ CLAUDE_MD_BLOCK,
401
+ "<!-- godxjp-ui:start",
402
+ "<!-- godxjp-ui:end -->",
270
403
  );
404
+ if (next === null)
405
+ return refuseAndSuggest(path, CLAUDE_MD_BLOCK, "godxjp-ui markers are broken");
406
+ writeFileAtomic(path, next);
271
407
  return "refreshed";
272
408
  }
273
409
  if (existing == null) {
274
- writeFileSync(path, CLAUDE_MD_BLOCK);
410
+ writeFileAtomic(path, CLAUDE_MD_BLOCK);
275
411
  return "created";
276
412
  }
277
- writeFileSync(path, existing.replace(/\s*$/, "") + "\n\n" + CLAUDE_MD_BLOCK);
413
+ writeFileAtomic(path, existing.replace(/\s*$/, "") + "\n\n" + CLAUDE_MD_BLOCK);
278
414
  return "appended";
279
415
  }
280
416
 
@@ -323,8 +459,8 @@ export function refreshGuineaPigSkill(root) {
323
459
  const current = readFileSync(target, "utf8");
324
460
  const marker = "\n---\n\n# 8. ";
325
461
  const i = current.indexOf(marker);
326
- writeFileSync(target, base.replace(/\s*$/, "") + "\n" + (i < 0 ? "" : current.slice(i)));
327
- writeFileSync(optin, `${stamp}\n`);
462
+ writeFileAtomic(target, base.replace(/\s*$/, "") + "\n" + (i < 0 ? "" : current.slice(i)));
463
+ writeFileAtomic(optin, `${stamp}\n`);
328
464
  return true;
329
465
  }
330
466
 
@@ -370,7 +506,7 @@ export function ensureConsumerRules(root) {
370
506
  return false;
371
507
  }
372
508
  mkdirSync(dir, { recursive: true });
373
- writeFileSync(target, next);
509
+ writeFileAtomic(target, next);
374
510
 
375
511
  // Prettier and this writer were fighting over the same file: the body holds aligned markdown
376
512
  // tables, Prettier reformats them, the digest changes, the next install writes it back, and
@@ -383,7 +519,7 @@ export function ensureConsumerRules(root) {
383
519
  (line) => !cur.includes(line),
384
520
  );
385
521
  if (owned.length) {
386
- writeFileSync(
522
+ writeFileAtomic(
387
523
  ignoreFile,
388
524
  `${cur.replace(/\s*$/, "")}\n\n# Owned by @godxjp/ui — rewritten on every install, never hand-formatted.\n${owned.join("\n")}\n`,
389
525
  );
@@ -396,7 +532,7 @@ export function ensureConsumerRules(root) {
396
532
  if (existsSync(index)) {
397
533
  const cur = readFileSync(index, "utf8");
398
534
  if (!cur.includes(".ai/rules/godxjp-ui.md")) {
399
- writeFileSync(
535
+ writeFileAtomic(
400
536
  index,
401
537
  cur.replace(/\s*$/, "") + `\n| ${uiDir}/** | .ai/rules/godxjp-ui.md |\n`,
402
538
  );
@@ -20,13 +20,22 @@ if (skip) process.exit(0); // silent: CI / opt-out / self-install / no consumer
20
20
 
21
21
  try {
22
22
  const r = ensureMcpJson(root);
23
+ // A refusal is a full sentence, not one of the three status words — say it on its own line
24
+ // rather than folding it into "MCP in .mcp.json (…)", where it would read as a success.
25
+ if (r.startsWith("left untouched")) {
26
+ console.log(`\n @godxjp/ui → .mcp.json ${r}\n`);
27
+ }
23
28
  // The mandate is plain text the agent reads every turn (CLAUDE.md block + workflow file). It
24
29
  // changes nothing in the dev loop, so it is installed by default: an agent that never saw the
25
30
  // but no mandate). Only the hooks — which DO change the loop — stay behind `init-agent`.
26
31
  const md = ensureClaudeMd(root);
32
+ if (md.startsWith("left untouched")) {
33
+ console.log(` @godxjp/ui → CLAUDE.md ${md}\n`);
34
+ }
27
35
  const wf = writeWorkflowMd(root);
28
36
  const skill = refreshGuineaPigSkill(root);
29
37
  const rules = ensureConsumerRules(root);
38
+ if (r.startsWith("left untouched") || md.startsWith("left untouched")) process.exit(0); // already reported
30
39
  if (r === "present" && md === "present" && !wf && !skill && !rules) process.exit(0); // current — stay quiet
31
40
  console.log(
32
41
  `\n @godxjp/ui → MCP in .mcp.json (${r}); workflow mandate in CLAUDE.md (${md}).\n` +
@@ -32,29 +32,72 @@ const dirArgs = args.filter((a) => !a.startsWith("--") && a !== "json");
32
32
  * local gate or in CI then covers every edit path, including the ones nobody thought of.
33
33
  */
34
34
  function changedFiles() {
35
- const base =
36
- spawnSync("git", ["merge-base", "HEAD", "origin/main"], { encoding: "utf8" }).stdout.trim() ||
37
- "HEAD";
38
- const run = (a) => spawnSync("git", a, { encoding: "utf8" }).stdout ?? "";
35
+ // A git command that FAILED used to be indistinguishable from one that found nothing: `run`
36
+ // returned `stdout ?? ""`, so a shallow clone with no `origin/main`, or a directory that is not
37
+ // a repository at all, produced an empty list and a clean, green, zero-exit run (gh#542).
38
+ const run = (a) => {
39
+ const r = spawnSync("git", a, { encoding: "utf8" });
40
+ return r.status === 0 ? (r.stdout ?? "") : null;
41
+ };
39
42
 
40
- return [
41
- ...new Set(
42
- [
43
- run(["diff", "--name-only", "--diff-filter=ACMR", base, "--"]),
44
- run(["diff", "--name-only", "--diff-filter=ACMR", "--cached"]),
45
- run(["ls-files", "--others", "--exclude-standard"]),
46
- ]
47
- .join("\n")
48
- .split("\n")
49
- .map((f) => f.trim())
50
- .filter((f) => /\.(tsx|jsx)$/.test(f) && existsSync(join(CWD, f))),
51
- ),
43
+ const mergeBase = run(["merge-base", "HEAD", "origin/main"]);
44
+ if (mergeBase === null) {
45
+ return {
46
+ error:
47
+ "ui-audit --changed could not resolve `git merge-base HEAD origin/main`. " +
48
+ "Without a base there is no such thing as \u201cwhat this branch changed\u201d, and reporting a " +
49
+ "clean audit from that is not a result. Fetch origin/main (a shallow clone may need " +
50
+ "`git fetch --unshallow`), or pass the directories to scan instead of `--changed`.",
51
+ };
52
+ }
53
+
54
+ const parts = [
55
+ run(["diff", "--name-only", "--diff-filter=ACMR", mergeBase.trim(), "--"]),
56
+ run(["diff", "--name-only", "--diff-filter=ACMR", "--cached"]),
57
+ run(["ls-files", "--others", "--exclude-standard"]),
52
58
  ];
59
+ if (parts.some((out) => out === null)) {
60
+ return {
61
+ error:
62
+ "ui-audit --changed: a `git diff`/`git ls-files` call failed; refusing to report a clean run.",
63
+ };
64
+ }
65
+
66
+ return {
67
+ files: [
68
+ ...new Set(
69
+ parts
70
+ .join("\n")
71
+ .split("\n")
72
+ .map((f) => f.trim())
73
+ // SCANNABLE, not a second hand-written list. Selecting `.jsx` here while `walk()` below
74
+ // accepted only `.tsx`/`.ts` meant a changed `.jsx` was picked, counted in the summary
75
+ // line as scanned, and then dropped by the walker without being opened — and if it was
76
+ // the only change, the run exited 0 saying "no .tsx/.jsx changed" (gh#542).
77
+ .filter((f) => SCANNABLE.test(f) && existsSync(join(CWD, f))),
78
+ ),
79
+ ],
80
+ };
53
81
  }
54
82
 
83
+ /**
84
+ * The ONE extension set. `changedFiles()` selects with it and `walk()` admits with it, so the
85
+ * selector and the walker cannot drift apart again — that drift was gh#542.
86
+ */
87
+ const SCANNABLE = /\.(tsx|jsx|ts)$/;
88
+
55
89
  const CHANGED = args.includes("--changed");
90
+ const changed = CHANGED ? changedFiles() : null;
91
+ if (changed?.error) {
92
+ if (asJson) {
93
+ process.stdout.write(JSON.stringify({ error: changed.error }, null, 2) + "\n");
94
+ } else {
95
+ console.error(changed.error);
96
+ }
97
+ process.exit(2);
98
+ }
56
99
  const SCAN_DIRS = CHANGED
57
- ? changedFiles()
100
+ ? changed.files
58
101
  : dirArgs.length
59
102
  ? dirArgs
60
103
  : SELF
@@ -728,7 +771,7 @@ function walk(dir, acc = []) {
728
771
  // Accept a FILE path directly (the per-file editor hook passes one), not just a directory.
729
772
  try {
730
773
  if (statSync(dir).isFile()) {
731
- if (dir.endsWith(".tsx") || dir.endsWith(".ts")) acc.push(dir);
774
+ if (SCANNABLE.test(dir)) acc.push(dir);
732
775
  return acc;
733
776
  }
734
777
  } catch {
@@ -747,10 +790,7 @@ function walk(dir, acc = []) {
747
790
  // Test/story dirs are not product UI — never hold them to the UI-standardization rules.
748
791
  if (name === "__tests__" || name === "node_modules") continue;
749
792
  walk(full, acc);
750
- } else if (
751
- (name.endsWith(".tsx") || name.endsWith(".ts")) &&
752
- !/\.(test|spec|stories)\.tsx?$/.test(name)
753
- ) {
793
+ } else if (SCANNABLE.test(name) && !/\.(test|spec|stories)\.[jt]sx?$/.test(name)) {
754
794
  acc.push(full);
755
795
  }
756
796
  }
@@ -880,12 +920,15 @@ const findings = [];
880
920
  const stale = staleOwnedRules();
881
921
  if (stale) findings.push(stale);
882
922
  let filesScanned = 0;
923
+ /** What was actually OPENED. The summary used to name the selection instead (gh#542). */
924
+ const scannedFiles = [];
883
925
  for (const dir of SCAN_DIRS) {
884
926
  for (const file of walk(isAbsolute(dir) ? dir : join(CWD, dir))) {
885
927
  const rel = relative(CWD, file);
886
928
  // Framework test support is executable fixture markup, not a shipped product screen.
887
929
  if (SELF && !args.includes("--consumer") && rel.startsWith("src/test/")) continue;
888
930
  filesScanned += 1;
931
+ scannedFiles.push(rel);
889
932
  // A primitive implements native controls; asking Input to render Input recurses.
890
933
  // Consumer applications and executable docs still receive these composition checks.
891
934
  const fileRules =
@@ -902,7 +945,7 @@ for (const dir of SCAN_DIRS) {
902
945
  /** Both opt-outs, by line index: the per-line markers and the reason-carrying block. */
903
946
  const suppressed = (ruleId, i) =>
904
947
  isSuppressed(ruleId, origLines[i], origLines[i - 1]) || inDisabledBlock(ruleId, i);
905
- const isJsx = file.endsWith(".tsx");
948
+ const isJsx = file.endsWith(".tsx") || file.endsWith(".jsx");
906
949
  // This compiler output intentionally resolves CSS variables to email-safe literals.
907
950
  // gen-email-tokens.mjs --check verifies it against its canonical token sources.
908
951
  const compiledEmailTokens =
@@ -1118,7 +1161,7 @@ if (filesScanned === 0) {
1118
1161
  console.log(` ${C.dim}${f.snippet}${C.reset}`);
1119
1162
  }
1120
1163
  console.log(
1121
- `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset} across ${SCAN_DIRS.join(", ")}.`,
1164
+ `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset} across ${scannedFiles.join(", ")}.`,
1122
1165
  );
1123
1166
  if (errors.length === 0 && warnings.length === 0) {
1124
1167
  console.log("✓ No UI-standardization violations found.");