magus-cli 7.3.1 → 7.4.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 +4 -4
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +107 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +173 -101
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +129 -129
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magus-cli",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.4.1",
|
|
4
4
|
"description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.tsx",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^5.6.3"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
|
-
"magus-cli-darwin-arm64": "7.
|
|
68
|
-
"magus-cli-darwin-x64": "7.
|
|
69
|
-
"magus-cli-linux-x64": "7.
|
|
67
|
+
"magus-cli-darwin-arm64": "7.4.1",
|
|
68
|
+
"magus-cli-darwin-x64": "7.4.1",
|
|
69
|
+
"magus-cli-linux-x64": "7.4.1"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C1 (requirements R1.1, R1.3, R1.4, R1.5).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only. No implementation
|
|
5
|
+
* file was read. Modules are loaded with a dynamic import inside each test, so
|
|
6
|
+
* a module that does not exist yet fails its own tests one by one instead of
|
|
7
|
+
* failing the whole file at load time.
|
|
8
|
+
*
|
|
9
|
+
* In-process: only pure contract values — MAGUS_NPM_PACKAGE, selfUpgradeCommand()
|
|
10
|
+
* (returns a command, runs nothing) and the cliTools constant.
|
|
11
|
+
*
|
|
12
|
+
* Child process: checkForUpdates does I/O (network, and possibly a cache under
|
|
13
|
+
* the home dir). Under Bun a runtime HOME change does not move os.homedir(),
|
|
14
|
+
* so it runs in a child `bun` with a temp HOME (see ./child-run.ts), with the
|
|
15
|
+
* fetch mock installed in the child before the module is imported. The child
|
|
16
|
+
* reports only the URLs it was asked for.
|
|
17
|
+
*/
|
|
18
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { runInChild } from "./child-run.js";
|
|
23
|
+
|
|
24
|
+
const PACKAGE_JSON = path.resolve(import.meta.dir, "../../../package.json");
|
|
25
|
+
const VERSION_CHECK_MODULE = path.resolve(
|
|
26
|
+
import.meta.dir,
|
|
27
|
+
"../../services/version-check.ts",
|
|
28
|
+
);
|
|
29
|
+
const REGISTRY_URL = "https://registry.npmjs.org/magus-cli/latest";
|
|
30
|
+
const T = 30_000; // per-test timeout for tests that spawn a child bun
|
|
31
|
+
|
|
32
|
+
/** Matches a bare `magus` package token, but not `magus-cli`. */
|
|
33
|
+
const BARE_MAGUS = /(?<![\w-])magus(?![\w-])/;
|
|
34
|
+
|
|
35
|
+
const loadNpmPackage = () => import("../../data/npm-package.js");
|
|
36
|
+
const loadUpgrade = () => import("../../cli/upgrade.js");
|
|
37
|
+
const loadCliTools = () => import("../../data/cli-tools.js");
|
|
38
|
+
|
|
39
|
+
const dirs: string[] = [];
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const dir of dirs.splice(0))
|
|
42
|
+
rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function tempDir(tag: string): string {
|
|
46
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), `magus-blind-c1-${tag}-`));
|
|
47
|
+
dirs.push(dir);
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Installed in the child before version-check is imported. */
|
|
52
|
+
const FETCH_MOCK = `
|
|
53
|
+
const requested = [];
|
|
54
|
+
const toUrl = (i) => typeof i === "string" ? i : i instanceof URL ? i.href : (i && i.url) || String(i);
|
|
55
|
+
globalThis.fetch = async (input) => {
|
|
56
|
+
requested.push(toUrl(input));
|
|
57
|
+
return new Response(JSON.stringify({ name: "magus-cli", version: "99.0.0" }), {
|
|
58
|
+
status: 200,
|
|
59
|
+
headers: { "content-type": "application/json" },
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
/** Calls checkForUpdates(true); reports the URLs requested and whether it threw (by class only). */
|
|
65
|
+
const FORCED_CHECK = `
|
|
66
|
+
try {
|
|
67
|
+
await mod.checkForUpdates(true);
|
|
68
|
+
await out({ stage: "done", ok: true, requested });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
await out({ stage: "done", ok: false, name: (e && e.name) || typeof e, requested });
|
|
71
|
+
}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
function forcedCheckUrls(): string[] {
|
|
75
|
+
const r = runInChild({
|
|
76
|
+
home: tempDir("home"),
|
|
77
|
+
cwd: tempDir("cwd"),
|
|
78
|
+
module: VERSION_CHECK_MODULE,
|
|
79
|
+
setup: FETCH_MOCK,
|
|
80
|
+
body: FORCED_CHECK,
|
|
81
|
+
});
|
|
82
|
+
expect(r).toMatchObject({ stage: "done" });
|
|
83
|
+
return r.requested as string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("C1 — the npm package name is magus-cli, defined once", () => {
|
|
87
|
+
test("R1.5 control: tools/magus/package.json names the package magus-cli", () => {
|
|
88
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
|
|
89
|
+
expect(pkg.name).toBe("magus-cli");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("R1.5 MAGUS_NPM_PACKAGE is magus-cli", async () => {
|
|
93
|
+
const { MAGUS_NPM_PACKAGE } = await loadNpmPackage();
|
|
94
|
+
expect(MAGUS_NPM_PACKAGE).toBe("magus-cli");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("R1.5 MAGUS_NPM_PACKAGE equals the name field of tools/magus/package.json", async () => {
|
|
98
|
+
const { MAGUS_NPM_PACKAGE } = await loadNpmPackage();
|
|
99
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
|
|
100
|
+
expect(MAGUS_NPM_PACKAGE).toBe(pkg.name);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("C1 — magus upgrade installs magus-cli@latest", () => {
|
|
105
|
+
// Contract amended at the merge with origin/main (see requirements R1.1): the
|
|
106
|
+
// released 7.3.1 API is selfUpgradeCommand → { cmd, args } with `install -g`.
|
|
107
|
+
test("R1.1 selfUpgradeCommand('bun') is `bun install -g magus-cli@latest`", async () => {
|
|
108
|
+
const { selfUpgradeCommand } = await loadUpgrade();
|
|
109
|
+
expect(selfUpgradeCommand("bun")).toEqual({
|
|
110
|
+
cmd: "bun",
|
|
111
|
+
args: ["install", "-g", "magus-cli@latest"],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("R1.1 selfUpgradeCommand('npm') is `npm install -g magus-cli@latest`", async () => {
|
|
116
|
+
const { selfUpgradeCommand } = await loadUpgrade();
|
|
117
|
+
expect(selfUpgradeCommand("npm")).toEqual({
|
|
118
|
+
cmd: "npm",
|
|
119
|
+
args: ["install", "-g", "magus-cli@latest"],
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("R1.1 neither upgrade command names the bare `magus` package", async () => {
|
|
124
|
+
const { selfUpgradeCommand } = await loadUpgrade();
|
|
125
|
+
for (const pm of ["bun", "npm"] as const) {
|
|
126
|
+
const { args } = selfUpgradeCommand(pm);
|
|
127
|
+
const bare = args.filter((a: string) => BARE_MAGUS.test(a));
|
|
128
|
+
expect({ pm, bare }).toEqual({ pm, bare: [] });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("R1.1/R1.5 the upgrade package spec is MAGUS_NPM_PACKAGE@latest", async () => {
|
|
133
|
+
const { selfUpgradeCommand } = await loadUpgrade();
|
|
134
|
+
const { MAGUS_NPM_PACKAGE } = await loadNpmPackage();
|
|
135
|
+
for (const pm of ["bun", "npm"] as const) {
|
|
136
|
+
expect(selfUpgradeCommand(pm).args).toContain(
|
|
137
|
+
`${MAGUS_NPM_PACKAGE}@latest`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("C1 — the update check asks the magus-cli registry document", () => {
|
|
144
|
+
test(
|
|
145
|
+
"R1.3 checkForUpdates(true) requests https://registry.npmjs.org/magus-cli/latest",
|
|
146
|
+
() => {
|
|
147
|
+
const requested = forcedCheckUrls();
|
|
148
|
+
// The forced check must actually go to the network (through the mock).
|
|
149
|
+
expect(requested.length).toBeGreaterThanOrEqual(1);
|
|
150
|
+
// Every request it made is the magus-cli document — never /magus/latest.
|
|
151
|
+
expect(requested).toEqual(requested.map(() => REGISTRY_URL));
|
|
152
|
+
},
|
|
153
|
+
T,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
test(
|
|
157
|
+
"R1.3 the update check never asks for the bare `magus` registry document",
|
|
158
|
+
() => {
|
|
159
|
+
const bare = forcedCheckUrls().filter((u) =>
|
|
160
|
+
/registry\.npmjs\.org\/magus(\/|$)/.test(u),
|
|
161
|
+
);
|
|
162
|
+
expect(bare).toEqual([]);
|
|
163
|
+
},
|
|
164
|
+
T,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("C1 — the CLI Tools entry for magus uses magus-cli", () => {
|
|
169
|
+
test("R1.4 the cliTools entry named magus exists", async () => {
|
|
170
|
+
const { cliTools } = await loadCliTools();
|
|
171
|
+
expect(
|
|
172
|
+
cliTools.find((t: { name: string }) => t.name === "magus"),
|
|
173
|
+
).toBeDefined();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("R1.4 the magus CLI Tools entry has packageName magus-cli", async () => {
|
|
177
|
+
const { cliTools } = await loadCliTools();
|
|
178
|
+
const entry = cliTools.find((t: { name: string }) => t.name === "magus");
|
|
179
|
+
expect(entry?.packageName).toBe("magus-cli");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("R1.4/R1.5 the magus CLI Tools entry's packageName is MAGUS_NPM_PACKAGE", async () => {
|
|
183
|
+
const { cliTools } = await loadCliTools();
|
|
184
|
+
const { MAGUS_NPM_PACKAGE } = await loadNpmPackage();
|
|
185
|
+
const entry = cliTools.find((t: { name: string }) => t.name === "magus");
|
|
186
|
+
expect(entry?.packageName).toBe(MAGUS_NPM_PACKAGE);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("R1.4 any install/add command string on the magus entry installs magus-cli, never bare magus", async () => {
|
|
190
|
+
// The contract elides the entry's other fields (`/*…*/`). If one of them
|
|
191
|
+
// is a displayed command, it must name magus-cli. With no such field the
|
|
192
|
+
// check has nothing to inspect and passes; packageName is pinned above.
|
|
193
|
+
const { cliTools } = await loadCliTools();
|
|
194
|
+
const entry = cliTools.find((t: { name: string }) => t.name === "magus");
|
|
195
|
+
expect(entry).toBeDefined();
|
|
196
|
+
const offenders: string[] = [];
|
|
197
|
+
for (const [key, value] of Object.entries(entry ?? {})) {
|
|
198
|
+
if (typeof value !== "string") continue;
|
|
199
|
+
if (!/\b(install|add)\b/.test(value)) continue;
|
|
200
|
+
if (!value.includes("magus-cli") || BARE_MAGUS.test(value)) {
|
|
201
|
+
offenders.push(`${key}: ${value}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
expect(offenders).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C10 (requirements R10.2, R10.3).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only. Two kinds of check:
|
|
5
|
+
* - the exported `theme` object is walked key by key, at every depth;
|
|
6
|
+
* - `src/ui/**` source text is scanned (theme.ts and theme-mode.ts excepted)
|
|
7
|
+
* and every offending `file:line` is listed in the failure.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const loadTheme = () => import("../../ui/theme.js");
|
|
14
|
+
|
|
15
|
+
const UI_DIR = path.resolve(import.meta.dir, "../../ui");
|
|
16
|
+
/** Paths relative to src/ui that may hold colour literals. */
|
|
17
|
+
const EXEMPT = new Set(["theme.ts", "theme-mode.ts"]);
|
|
18
|
+
|
|
19
|
+
const COLOUR_WORDS = [
|
|
20
|
+
"purple",
|
|
21
|
+
"green",
|
|
22
|
+
"teal",
|
|
23
|
+
"yellow",
|
|
24
|
+
"gray",
|
|
25
|
+
"grey",
|
|
26
|
+
"red",
|
|
27
|
+
"blue",
|
|
28
|
+
"orange",
|
|
29
|
+
"cyan",
|
|
30
|
+
"magenta",
|
|
31
|
+
"pink",
|
|
32
|
+
"violet",
|
|
33
|
+
"amber",
|
|
34
|
+
];
|
|
35
|
+
const COLOUR_WORD_SET = new Set(COLOUR_WORDS);
|
|
36
|
+
|
|
37
|
+
/** CSS named colours likely to be typed as a literal (R10.3 "named CSS colour string"). */
|
|
38
|
+
const CSS_NAMED = new Set([
|
|
39
|
+
...COLOUR_WORDS.filter((w) => w !== "amber"),
|
|
40
|
+
"black",
|
|
41
|
+
"white",
|
|
42
|
+
"silver",
|
|
43
|
+
"maroon",
|
|
44
|
+
"olive",
|
|
45
|
+
"lime",
|
|
46
|
+
"aqua",
|
|
47
|
+
"navy",
|
|
48
|
+
"fuchsia",
|
|
49
|
+
"brown",
|
|
50
|
+
"gold",
|
|
51
|
+
"indigo",
|
|
52
|
+
"coral",
|
|
53
|
+
"salmon",
|
|
54
|
+
"crimson",
|
|
55
|
+
"lightgray",
|
|
56
|
+
"lightgrey",
|
|
57
|
+
"darkgray",
|
|
58
|
+
"darkgrey",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** Split an identifier into lowercase words: "textGray500" -> ["text","gray","500"]. */
|
|
62
|
+
function words(name: string): string[] {
|
|
63
|
+
return name
|
|
64
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
65
|
+
.replace(/([A-Za-z])([0-9])/g, "$1 $2")
|
|
66
|
+
.split(/[^A-Za-z0-9]+/)
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.map((w) => w.toLowerCase());
|
|
69
|
+
}
|
|
70
|
+
const colourWordIn = (name: string) =>
|
|
71
|
+
words(name).find((w) => COLOUR_WORD_SET.has(w));
|
|
72
|
+
|
|
73
|
+
function walkFiles(dir: string): string[] {
|
|
74
|
+
const out: string[] = [];
|
|
75
|
+
for (const name of readdirSync(dir)) {
|
|
76
|
+
const full = path.join(dir, name);
|
|
77
|
+
if (statSync(full).isDirectory()) out.push(...walkFiles(full));
|
|
78
|
+
else if (/\.(ts|tsx)$/.test(name)) out.push(full);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function uiFiles(): string[] {
|
|
84
|
+
return walkFiles(UI_DIR).filter((f) => !EXEMPT.has(path.relative(UI_DIR, f)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isCommentLine(line: string): boolean {
|
|
88
|
+
const t = line.trim();
|
|
89
|
+
return (
|
|
90
|
+
t.startsWith("//") ||
|
|
91
|
+
t.startsWith("*") ||
|
|
92
|
+
t.startsWith("/*") ||
|
|
93
|
+
t.startsWith("{/*")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every non-comment line of every scanned file matching `rule`, as `file:line: text`. */
|
|
98
|
+
function scan(rule: (line: string) => boolean): string[] {
|
|
99
|
+
const hits: string[] = [];
|
|
100
|
+
for (const file of uiFiles()) {
|
|
101
|
+
readFileSync(file, "utf8")
|
|
102
|
+
.split("\n")
|
|
103
|
+
.forEach((line, i) => {
|
|
104
|
+
if (isCommentLine(line)) return;
|
|
105
|
+
if (rule(line)) {
|
|
106
|
+
hits.push(
|
|
107
|
+
`src/ui/${path.relative(UI_DIR, file)}:${i + 1}: ${line.trim().slice(0, 140)}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return hits;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const COLOUR_PROPS = "fg|bg|backgroundColor|borderColor|color";
|
|
116
|
+
const HEX_LITERAL =
|
|
117
|
+
/(["'`])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\1/;
|
|
118
|
+
const RGB_IN_STRING = /["'`][^"'`\n]*\brgba?\s*\(/;
|
|
119
|
+
/** fg="…", fg='…', fg={"…"}, fg={`…`} */
|
|
120
|
+
const JSX_PROP_LITERAL = new RegExp(
|
|
121
|
+
`(?<![\\w$.])(?:${COLOUR_PROPS})\\s*=\\s*(?:\\{\\s*)?["'\`]`,
|
|
122
|
+
);
|
|
123
|
+
/** fg={cond ? "…" : x} — a string literal inside the prop's expression. */
|
|
124
|
+
const JSX_PROP_TERNARY_LITERAL = new RegExp(
|
|
125
|
+
`(?<![\\w$.])(?:${COLOUR_PROPS})\\s*=\\s*\\{[^}]*[?:]\\s*["'\`]`,
|
|
126
|
+
);
|
|
127
|
+
/** { fg: "red" } — an object colour prop set to a named CSS colour. */
|
|
128
|
+
const OBJECT_PROP_NAMED = new RegExp(
|
|
129
|
+
`(?<![\\w$.])(?:${COLOUR_PROPS})\\s*:\\s*["'\`]([A-Za-z]+)["'\`]`,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
describe("C10 — scan setup", () => {
|
|
133
|
+
test("R10.3 control: the scan finds UI source files and exempts only theme.ts and theme-mode.ts", () => {
|
|
134
|
+
const all = walkFiles(UI_DIR).map((f) => path.relative(UI_DIR, f));
|
|
135
|
+
expect(all.length).toBeGreaterThan(10);
|
|
136
|
+
expect(all).toContain("theme.ts");
|
|
137
|
+
expect(uiFiles().length).toBe(
|
|
138
|
+
all.length - all.filter((f) => EXEMPT.has(f)).length,
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("R10.3 control: the rules fire on known-bad lines and stay quiet on role references", () => {
|
|
143
|
+
expect(HEX_LITERAL.test('const c = "#ff00aa";')).toBe(true);
|
|
144
|
+
expect(HEX_LITERAL.test("fg='#abc'")).toBe(true);
|
|
145
|
+
expect(HEX_LITERAL.test('x = "#aabbccdd"')).toBe(true);
|
|
146
|
+
expect(RGB_IN_STRING.test('bg: "rgb(1, 2, 3)"')).toBe(true);
|
|
147
|
+
expect(RGB_IN_STRING.test('bg: "rgba(1,2,3,0.5)"')).toBe(true);
|
|
148
|
+
expect(JSX_PROP_LITERAL.test('<text fg="gray">')).toBe(true);
|
|
149
|
+
expect(JSX_PROP_LITERAL.test("<box backgroundColor={'#000'}>")).toBe(true);
|
|
150
|
+
expect(
|
|
151
|
+
JSX_PROP_TERNARY_LITERAL.test(
|
|
152
|
+
'<text fg={on ? "green" : theme.colors.muted}>',
|
|
153
|
+
),
|
|
154
|
+
).toBe(true);
|
|
155
|
+
expect(OBJECT_PROP_NAMED.test('style={{ fg: "red" }}')).toBe(true);
|
|
156
|
+
const clean = "<text fg={theme.colors.accent} bg={theme.colors.surface}>";
|
|
157
|
+
expect(HEX_LITERAL.test(clean)).toBe(false);
|
|
158
|
+
expect(RGB_IN_STRING.test(clean)).toBe(false);
|
|
159
|
+
expect(JSX_PROP_LITERAL.test(clean)).toBe(false);
|
|
160
|
+
expect(JSX_PROP_TERNARY_LITERAL.test(clean)).toBe(false);
|
|
161
|
+
expect(OBJECT_PROP_NAMED.test(clean)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("C10 — no colour literal in src/ui outside the theme files (R10.3)", () => {
|
|
166
|
+
test('R10.3 no hex colour string literal ("#abc", "#aabbcc", "#aabbccdd")', () => {
|
|
167
|
+
expect(scan((l) => HEX_LITERAL.test(l))).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("R10.3 no rgb( / rgba( inside a string", () => {
|
|
171
|
+
expect(scan((l) => RGB_IN_STRING.test(l))).toEqual([]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("R10.3 no JSX colour prop (fg, bg, backgroundColor, borderColor, color) set to a string literal", () => {
|
|
175
|
+
expect(
|
|
176
|
+
scan((l) => JSX_PROP_LITERAL.test(l) || JSX_PROP_TERNARY_LITERAL.test(l)),
|
|
177
|
+
).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("R10.3 no colour prop in an object set to a named CSS colour string", () => {
|
|
181
|
+
expect(
|
|
182
|
+
scan((l) => {
|
|
183
|
+
const m = OBJECT_PROP_NAMED.exec(l);
|
|
184
|
+
return !!m && CSS_NAMED.has((m[1] as string).toLowerCase());
|
|
185
|
+
}),
|
|
186
|
+
).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("C10 — every colour is a role, never a colour name (R10.2)", () => {
|
|
191
|
+
test("R10.2 control: the colour-word splitter catches camelCase, snake_case and digits, not substrings", () => {
|
|
192
|
+
expect(colourWordIn("purple")).toBe("purple");
|
|
193
|
+
expect(colourWordIn("textGray")).toBe("gray");
|
|
194
|
+
expect(colourWordIn("gray500")).toBe("gray");
|
|
195
|
+
expect(colourWordIn("bg_red")).toBe("red");
|
|
196
|
+
expect(colourWordIn("redraw")).toBeUndefined();
|
|
197
|
+
expect(colourWordIn("reduced")).toBeUndefined();
|
|
198
|
+
expect(colourWordIn("accent")).toBeUndefined();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("R10.2 no key of the exported theme, at any depth, is named after a colour", async () => {
|
|
202
|
+
const { theme } = await loadTheme();
|
|
203
|
+
expect(theme).toBeDefined();
|
|
204
|
+
const offenders: string[] = [];
|
|
205
|
+
const seen = new Set<unknown>();
|
|
206
|
+
const visit = (value: unknown, at: string) => {
|
|
207
|
+
if (value === null || typeof value !== "object" || seen.has(value))
|
|
208
|
+
return;
|
|
209
|
+
seen.add(value);
|
|
210
|
+
const proto = Object.getPrototypeOf(value);
|
|
211
|
+
const plain =
|
|
212
|
+
proto === Object.prototype || proto === null || Array.isArray(value);
|
|
213
|
+
if (!plain) return; // a colour value object (e.g. an RGBA instance), not a namespace
|
|
214
|
+
for (const key of Object.keys(value)) {
|
|
215
|
+
const word = colourWordIn(key);
|
|
216
|
+
if (word) offenders.push(`theme${at}.${key} (${word})`);
|
|
217
|
+
visit((value as Record<string, unknown>)[key], `${at}.${key}`);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
visit(theme, "");
|
|
221
|
+
expect(offenders).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("R10.2 the theme walk visits some keys (control)", async () => {
|
|
225
|
+
const { theme } = await loadTheme();
|
|
226
|
+
const count = (v: unknown): number =>
|
|
227
|
+
v && typeof v === "object"
|
|
228
|
+
? Object.keys(v).reduce(
|
|
229
|
+
(n, k) => n + 1 + count((v as Record<string, unknown>)[k]),
|
|
230
|
+
0,
|
|
231
|
+
)
|
|
232
|
+
: 0;
|
|
233
|
+
expect(count(theme)).toBeGreaterThan(3);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("R10.2 no prop or variable in src/ui is named after a colour", () => {
|
|
237
|
+
// Strings and trailing comments are blanked first so text such as
|
|
238
|
+
// "Green means installed" is not read as a name. Checked: declarations,
|
|
239
|
+
// property accesses, object keys and JSX props.
|
|
240
|
+
const NAME = "[A-Za-z_$][\\w$]*";
|
|
241
|
+
const patterns = [
|
|
242
|
+
new RegExp(
|
|
243
|
+
`\\b(?:const|let|var|function|class|type|interface)\\s+(${NAME})`,
|
|
244
|
+
"g",
|
|
245
|
+
),
|
|
246
|
+
new RegExp(`\\.(${NAME})`, "g"),
|
|
247
|
+
new RegExp(`(?:^|[\\s{(,])(${NAME})\\??\\s*:(?!:)`, "g"),
|
|
248
|
+
new RegExp(`(?:^|\\s)(${NAME})\\s*=(?![=>])`, "g"),
|
|
249
|
+
new RegExp(`\\{\\s*(${NAME})\\s*[,}]`, "g"),
|
|
250
|
+
];
|
|
251
|
+
const hits = scan((raw) => {
|
|
252
|
+
const code = raw
|
|
253
|
+
.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""')
|
|
254
|
+
.replace(/\/\/.*$/, "")
|
|
255
|
+
.replace(/\/\*.*?\*\//g, "");
|
|
256
|
+
for (const p of patterns) {
|
|
257
|
+
for (const m of code.matchAll(p)) {
|
|
258
|
+
if (colourWordIn(m[1] as string)) return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
});
|
|
263
|
+
expect(hits).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
});
|