@godxjp/ui 23.2.0 → 23.3.0

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.
@@ -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
  }
@@ -851,16 +891,47 @@ function staleOwnedRules() {
851
891
  const target = join(CWD, ".ai", "rules", "godxjp-ui.md");
852
892
  if (!existsSync(target)) return null;
853
893
 
854
- const stamped = /<!-- godxjp-ui:version ([^\s]+) -->/.exec(readFileSync(target, "utf8"))?.[1];
894
+ // FOUR states, not two. `!stamped || !installed || stamped === installed` collapsed three very
895
+ // different situations into one silence, and only ONE of them is genuinely fine:
896
+ //
897
+ // no rule file, consumer never opted into the agent kit -> silent, and it MUST stay silent.
898
+ // Turning this into a finding would make a UI audit into a tool that nags every consumer to
899
+ // install an agent kit they did not ask for. (Handled by the existsSync above.)
900
+ // rule file present but carrying NO stamp -> compatibility UNKNOWN, say so.
901
+ // installed version unreadable -> compatibility UNKNOWN, say so.
902
+ // stamp != installed -> stale, the original finding.
903
+ const contents = readFileSync(target, "utf8");
904
+ const stamped = /<!-- godxjp-ui:version ([^\s]+) -->/.exec(contents)?.[1];
905
+
855
906
  let installed;
856
907
  try {
857
908
  installed = JSON.parse(
858
909
  readFileSync(join(CWD, "node_modules", "@godxjp", "ui", "package.json"), "utf8"),
859
910
  ).version;
860
911
  } catch {
861
- return null;
912
+ installed = undefined;
913
+ }
914
+
915
+ if (!stamped || !installed) {
916
+ // A file the package OWNS, whose provenance cannot be established. That is not the same as
917
+ // "up to date", and reporting it as such is the whole class of bug this function exists for.
918
+ return {
919
+ file: ".ai/rules/godxjp-ui.md",
920
+ line: 1,
921
+ rule: "owned-rules-unknown",
922
+ severity: "warn",
923
+ message:
924
+ `This file is written by @godxjp/ui, but its version cannot be established ` +
925
+ `(${!stamped ? "the file carries no `<!-- godxjp-ui:version … -->` stamp" : "the installed package version could not be read"}). ` +
926
+ `An agent may be reading guidance from a different major. Refresh it with ` +
927
+ `\`npx @godxjp/ui init-agent\`, or delete the file if this project does not use the agent kit.`,
928
+ replacement: null,
929
+ standard: null,
930
+ snippet: (stamped ?? "(no stamp)") + " vs " + (installed ?? "(package version unreadable)"),
931
+ };
862
932
  }
863
- if (!stamped || !installed || stamped === installed) return null;
933
+
934
+ if (stamped === installed) return null;
864
935
 
865
936
  return {
866
937
  file: ".ai/rules/godxjp-ui.md",
@@ -880,12 +951,15 @@ const findings = [];
880
951
  const stale = staleOwnedRules();
881
952
  if (stale) findings.push(stale);
882
953
  let filesScanned = 0;
954
+ /** What was actually OPENED. The summary used to name the selection instead (gh#542). */
955
+ const scannedFiles = [];
883
956
  for (const dir of SCAN_DIRS) {
884
957
  for (const file of walk(isAbsolute(dir) ? dir : join(CWD, dir))) {
885
958
  const rel = relative(CWD, file);
886
959
  // Framework test support is executable fixture markup, not a shipped product screen.
887
960
  if (SELF && !args.includes("--consumer") && rel.startsWith("src/test/")) continue;
888
961
  filesScanned += 1;
962
+ scannedFiles.push(rel);
889
963
  // A primitive implements native controls; asking Input to render Input recurses.
890
964
  // Consumer applications and executable docs still receive these composition checks.
891
965
  const fileRules =
@@ -902,7 +976,7 @@ for (const dir of SCAN_DIRS) {
902
976
  /** Both opt-outs, by line index: the per-line markers and the reason-carrying block. */
903
977
  const suppressed = (ruleId, i) =>
904
978
  isSuppressed(ruleId, origLines[i], origLines[i - 1]) || inDisabledBlock(ruleId, i);
905
- const isJsx = file.endsWith(".tsx");
979
+ const isJsx = file.endsWith(".tsx") || file.endsWith(".jsx");
906
980
  // This compiler output intentionally resolves CSS variables to email-safe literals.
907
981
  // gen-email-tokens.mjs --check verifies it against its canonical token sources.
908
982
  const compiledEmailTokens =
@@ -1069,11 +1143,37 @@ const warnings = findings.filter((f) => f.severity === "warn");
1069
1143
  // "✓ No UI-standardization violations found." and exited 0 having read nothing at all.
1070
1144
  // …except under `--changed`, where "this branch touched no .tsx" is a clean run, not a
1071
1145
  // misconfigured path. Failing there would make the gate unusable on every backend-only commit.
1072
- if (filesScanned === 0 && CHANGED) {
1073
- if (!quiet && !asJson) console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
1146
+ // "This branch touched no UI file" is a clean run — but ONLY if there is nothing else to say.
1147
+ //
1148
+ // `staleOwnedRules()` runs before the scan loop and pushes its finding into `findings`, and this
1149
+ // branch used to exit 0 regardless: a consumer whose package-owned rules were four majors out of
1150
+ // date got "✓ no .tsx/.jsx changed" and a clean exit on any commit that happened not to touch a
1151
+ // component. Under `--format json` it was worse — the exit came BEFORE anything was written, so
1152
+ // stdout was EMPTY and a CI step reading it could not tell that from a pass.
1153
+ //
1154
+ // Reproduced: rules stamped 19.6.0 against an installed 23.3.0, one README.md changed →
1155
+ // `✓ no .tsx/.jsx changed`, exit 0, empty JSON, while `ui-audit src --format json` on the same
1156
+ // tree at the same moment reported `owned-rules-stale`.
1157
+ //
1158
+ // Same defect as the `.jsx` one this file just fixed, one screen further down: a gate declaring
1159
+ // itself clean while holding a finding.
1160
+ /** `--changed` legitimately opened no file. NOT the same thing as a misconfigured scan path. */
1161
+ const changedNoFiles = CHANGED && filesScanned === 0;
1162
+
1163
+ if (changedNoFiles && findings.length === 0) {
1164
+ if (asJson) {
1165
+ // ALWAYS emit a valid document on the JSON path. This branch used to exit before writing
1166
+ // anything, so a CI step parsing stdout got an empty string and a zero exit.
1167
+ process.stdout.write(
1168
+ JSON.stringify({ summary: { errors: 0, warnings: 0 }, findings: [] }, null, 2) + "\n",
1169
+ );
1170
+ } else if (!quiet) {
1171
+ console.log("✓ ui-audit --changed: no .tsx/.jsx changed on this branch.");
1172
+ }
1074
1173
  process.exit(0);
1075
1174
  }
1076
- if (filesScanned === 0) {
1175
+
1176
+ if (filesScanned === 0 && !CHANGED) {
1077
1177
  const message =
1078
1178
  `ui-audit scanned 0 files — none of [${SCAN_DIRS.join(", ")}] exists (or all were filtered). ` +
1079
1179
  `Pass the directories to scan, e.g. \`node scripts/ui-audit.mjs src docs\`. ` +
@@ -1088,7 +1188,7 @@ if (filesScanned === 0) {
1088
1188
  process.exitCode = 2;
1089
1189
  }
1090
1190
 
1091
- if (filesScanned === 0) {
1191
+ if (filesScanned === 0 && !changedNoFiles) {
1092
1192
  // already reported above
1093
1193
  } else if (asJson) {
1094
1194
  process.stdout.write(
@@ -1118,7 +1218,10 @@ if (filesScanned === 0) {
1118
1218
  console.log(` ${C.dim}${f.snippet}${C.reset}`);
1119
1219
  }
1120
1220
  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(", ")}.`,
1221
+ `\ngodxjp-ui audit: ${C.red}${errors.length} error(s)${C.reset}, ${C.yellow}${warnings.length} warning(s)${C.reset}` +
1222
+ (scannedFiles.length > 0
1223
+ ? ` across ${scannedFiles.join(", ")}.`
1224
+ : " — no UI file changed on this branch, but the findings above are not about a file."),
1122
1225
  );
1123
1226
  if (errors.length === 0 && warnings.length === 0) {
1124
1227
  console.log("✓ No UI-standardization violations found.");
@@ -1126,4 +1229,4 @@ if (filesScanned === 0) {
1126
1229
  }
1127
1230
 
1128
1231
  // See the note above --rules: exitCode, so a large JSON report drains fully.
1129
- if (filesScanned > 0) process.exitCode = errors.length > 0 ? 1 : 0;
1232
+ if (filesScanned > 0 || changedNoFiles) process.exitCode = errors.length > 0 ? 1 : 0;