@xeonr/console-shell 0.5.7 → 0.5.9

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.
Files changed (48) hide show
  1. package/dist/CommandPalette.d.ts.map +1 -1
  2. package/dist/CommandPalette.js +2 -0
  3. package/dist/CommandPalette.js.map +1 -1
  4. package/dist/ConsoleShell.d.ts +10 -1
  5. package/dist/ConsoleShell.d.ts.map +1 -1
  6. package/dist/ConsoleShell.js +47 -4
  7. package/dist/ConsoleShell.js.map +1 -1
  8. package/dist/ScopeCard.d.ts.map +1 -1
  9. package/dist/ScopeCard.js +2 -0
  10. package/dist/ScopeCard.js.map +1 -1
  11. package/dist/client.d.ts +4 -1
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +8 -4
  14. package/dist/client.js.map +1 -1
  15. package/dist/devMock.d.ts.map +1 -1
  16. package/dist/devMock.js +7 -0
  17. package/dist/devMock.js.map +1 -1
  18. package/dist/gen/xeonr/console/v1/console_pb.d.ts +76 -0
  19. package/dist/gen/xeonr/console/v1/console_pb.d.ts.map +1 -1
  20. package/dist/gen/xeonr/console/v1/console_pb.js +24 -19
  21. package/dist/gen/xeonr/console/v1/console_pb.js.map +1 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/screen.d.ts +16 -0
  27. package/dist/screen.d.ts.map +1 -1
  28. package/dist/screen.js +14 -0
  29. package/dist/screen.js.map +1 -1
  30. package/dist/session.d.ts +148 -0
  31. package/dist/session.d.ts.map +1 -0
  32. package/dist/session.js +151 -0
  33. package/dist/session.js.map +1 -0
  34. package/dist/sessionReact.d.ts +41 -0
  35. package/dist/sessionReact.d.ts.map +1 -0
  36. package/dist/sessionReact.js +58 -0
  37. package/dist/sessionReact.js.map +1 -0
  38. package/dist/shellConfig.d.ts +3 -0
  39. package/dist/shellConfig.d.ts.map +1 -1
  40. package/dist/shellConfig.js.map +1 -1
  41. package/dist/switchNotice.d.ts +28 -0
  42. package/dist/switchNotice.d.ts.map +1 -0
  43. package/dist/switchNotice.js +20 -0
  44. package/dist/switchNotice.js.map +1 -0
  45. package/dist/types.d.ts +13 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/package.json +8 -4
  48. package/scripts/check-entity-crumbs.mjs +319 -0
@@ -0,0 +1,319 @@
1
+ #!/usr/bin/env node
2
+ // Entity-crumb check (NAV-DESIGN §6, NAV-CONTRACT §4, CON-41): a route whose
3
+ // own path has a `:param` renders an entity, and the entity's name must reach
4
+ // the trail — the screen calls `useCrumb` / `useCrumbs`, renders
5
+ // `EntityScreen`, or (a project or namespace) publishes it as the context with
6
+ // `useActiveContext`. Otherwise the crumb trail ends at the section, or worse,
7
+ // a layout joins URL segments and the id reaches the trail.
8
+ //
9
+ // The workspace has no shared eslint preset to carry a rule, and every
10
+ // console lints with `tsc --noEmit && xeonr-check-colour-literals`, so this
11
+ // is a sibling of that check rather than an eslint plugin: plain Node ESM, no
12
+ // dependencies, run from the package root:
13
+ //
14
+ // xeonr-check-entity-crumbs [--ignore-param team,ns] [dir…] (default dir: src)
15
+ //
16
+ // What it reads, heuristically (it is not a type checker):
17
+ // - route objects — `{ path: "t/:key", element: <TicketDetail /> }` — and
18
+ // `<Route path=":id" element={<X />} />` elements;
19
+ // - a route with `children` (or a JSX <Route> with children) is a layout
20
+ // and is skipped: its params are a scope or a context, not an entity;
21
+ // - params named by --ignore-param are skipped (default: team);
22
+ // - <Navigate>, elements named *Redirect, and components that render only
23
+ // <Navigate> (and a loading state) have nothing to name;
24
+ // - the element is followed to its definition (same file, a relative
25
+ // import, a re-export, or lazy(() => import(…))), and from there through
26
+ // the components it renders and the hooks it calls, four levels deep.
27
+ //
28
+ // Silence one route with a comment on its line or the line above:
29
+ // { path: "raw/:id", element: <Raw /> }, // entity-crumb: ok — a download, not a screen
30
+
31
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
32
+ import { dirname, join, relative, resolve } from "node:path";
33
+ import process from "node:process";
34
+
35
+ const PUBLISHES = /\buseCrumbs?\s*\(|<EntityScreen\b|\buseActiveContext\s*\(/;
36
+ const MAX_DEPTH = 4;
37
+
38
+ function parseArgs(argv) {
39
+ const ignore = new Set(["team"]);
40
+ const dirs = [];
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (a === "--ignore-param") {
44
+ for (const p of (argv[++i] ?? "").split(",")) if (p.trim()) ignore.add(p.trim());
45
+ } else if (a.startsWith("--ignore-param=")) {
46
+ for (const p of a.slice("--ignore-param=".length).split(",")) if (p.trim()) ignore.add(p.trim());
47
+ } else dirs.push(a);
48
+ }
49
+ return { ignore, dirs: dirs.length ? dirs : ["src"] };
50
+ }
51
+
52
+ function collect(dir) {
53
+ const out = [];
54
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
55
+ if (ent.name === "node_modules" || ent.name === "gen" || ent.name.startsWith(".")) continue;
56
+ const p = join(dir, ent.name);
57
+ if (ent.isDirectory()) out.push(...collect(p));
58
+ else if (/\.tsx?$/.test(ent.name) && !/\.(test|stories)\.tsx?$/.test(ent.name) && !ent.name.endsWith(".d.ts")) out.push(p);
59
+ }
60
+ return out.sort();
61
+ }
62
+
63
+ const cache = new Map();
64
+ function read(file) {
65
+ if (!cache.has(file)) cache.set(file, readFileSync(file, "utf8"));
66
+ return cache.get(file);
67
+ }
68
+
69
+ /** The index just past the bracket that closes the one at `open`, skipping strings. */
70
+ function matchClose(src, open) {
71
+ const pairs = { "{": "}", "[": "]", "(": ")" };
72
+ const stack = [pairs[src[open]]];
73
+ for (let i = open + 1; i < src.length; i++) {
74
+ const ch = src[i];
75
+ if (ch === '"' || ch === "'" || ch === "`") {
76
+ for (i++; i < src.length && src[i] !== ch; i++) if (src[i] === "\\") i++;
77
+ continue;
78
+ }
79
+ if (ch === "/" && src[i + 1] === "/") {
80
+ while (i < src.length && src[i] !== "\n") i++;
81
+ continue;
82
+ }
83
+ if (pairs[ch]) stack.push(pairs[ch]);
84
+ else if (ch === stack[stack.length - 1]) {
85
+ stack.pop();
86
+ if (stack.length === 0) return i + 1;
87
+ }
88
+ }
89
+ return src.length;
90
+ }
91
+
92
+ /** The `{` that opens the object literal containing `at`. */
93
+ function enclosingBrace(src, at) {
94
+ let depth = 0;
95
+ for (let i = at; i >= 0; i--) {
96
+ const ch = src[i];
97
+ if (ch === "}" || ch === "]" || ch === ")") depth++;
98
+ else if (ch === "{" || ch === "[" || ch === "(") {
99
+ if (depth === 0) return ch === "{" ? i : -1;
100
+ depth--;
101
+ }
102
+ }
103
+ return -1;
104
+ }
105
+
106
+ /** Blank every [...] inside an object's text, so a child route is not read as the parent's. */
107
+ function topLevel(obj) {
108
+ let out = "";
109
+ for (let i = 0; i < obj.length; i++) {
110
+ if (obj[i] === "[" && i > 0) {
111
+ const end = matchClose(obj, i);
112
+ out += " ".repeat(end - i);
113
+ i = end - 1;
114
+ } else out += obj[i];
115
+ }
116
+ return out;
117
+ }
118
+
119
+ const lineOf = (src, at) => src.slice(0, at).split("\n").length;
120
+
121
+ function silenced(src, at) {
122
+ const lines = src.split("\n");
123
+ const n = lineOf(src, at) - 1;
124
+ return /entity-crumb:\s*ok/.test(lines[n] ?? "") || /entity-crumb:\s*ok/.test(lines[n - 1] ?? "");
125
+ }
126
+
127
+ function entityParams(path, ignore) {
128
+ return [...path.matchAll(/:([A-Za-z_$][\w$]*)/g)].map((m) => m[1]).filter((p) => !ignore.has(p));
129
+ }
130
+
131
+ /** The components an element expression renders: `<Admin><TopicDetail /></Admin>` is both. */
132
+ function componentsIn(expr) {
133
+ return [...new Set([...expr.matchAll(/<\s*([A-Z][\w$]*)/g)].map((x) => x[1]))];
134
+ }
135
+
136
+ /** The route objects a route object sits under (its parents' `children` arrays), innermost first. */
137
+ function ancestorObjects(src, open) {
138
+ const out = [];
139
+ let at = open - 1;
140
+ for (;;) {
141
+ // the [ … ] this object is an element of, then the { … } that owns that array
142
+ let depth = 0;
143
+ let arr = -1;
144
+ for (let i = at; i >= 0; i--) {
145
+ const ch = src[i];
146
+ if (ch === "}" || ch === "]" || ch === ")") depth++;
147
+ else if (ch === "{" || ch === "(" || ch === "[") {
148
+ if (depth === 0) {
149
+ if (ch === "[") arr = i;
150
+ break;
151
+ }
152
+ depth--;
153
+ }
154
+ }
155
+ if (arr < 0) return out;
156
+ const parent = enclosingBrace(src, arr - 1);
157
+ if (parent < 0) return out;
158
+ out.push(topLevel(src.slice(parent, matchClose(src, parent))));
159
+ at = parent - 1;
160
+ }
161
+ }
162
+
163
+ /** Every route declaration in a file: { path, elements, parents, layout, at }. */
164
+ function routesIn(src) {
165
+ const out = [];
166
+ for (const m of src.matchAll(/\bpath\s*:\s*(["'`])([^"'`]*)\1/g)) {
167
+ const open = enclosingBrace(src, m.index);
168
+ if (open < 0) continue;
169
+ const obj = topLevel(src.slice(open, matchClose(src, open)));
170
+ const el = obj.match(/\belement\s*:\s*(<[\s\S]*)/);
171
+ // a layout above this route that names the entity covers it (fer's ref layout)
172
+ const parents = ancestorObjects(src, open).flatMap((o) => {
173
+ const pe = o.match(/\belement\s*:\s*(<[\s\S]*)/);
174
+ return pe ? componentsIn(pe[1]) : [];
175
+ });
176
+ out.push({ path: m[2], elements: el ? componentsIn(el[1]) : [], parents, layout: /\bchildren\s*:/.test(obj), at: m.index });
177
+ }
178
+ for (const m of src.matchAll(/<Route\b/g)) {
179
+ // the opening tag ends at the first `>` outside braces
180
+ let i = m.index + 6;
181
+ let depth = 0;
182
+ for (; i < src.length; i++) {
183
+ if (src[i] === "{") depth++;
184
+ else if (src[i] === "}") depth--;
185
+ else if (src[i] === ">" && depth === 0) break;
186
+ }
187
+ const tag = src.slice(m.index, i + 1);
188
+ const path = tag.match(/\bpath\s*=\s*(?:\{\s*)?(["'`])([^"'`]*)\1/);
189
+ if (!path) continue;
190
+ const el = tag.match(/\belement\s*=\s*\{\s*(<[\s\S]*)/);
191
+ out.push({ path: path[2], elements: el ? componentsIn(el[1]) : [], parents: [], layout: !tag.endsWith("/>"), at: m.index });
192
+ }
193
+ return out;
194
+ }
195
+
196
+ function resolveFile(from, spec) {
197
+ if (!spec.startsWith(".")) return null;
198
+ const base = resolve(dirname(from), spec.replace(/\.(js|jsx|mjs)$/, ""));
199
+ for (const c of [base, `${base}.tsx`, `${base}.ts`, join(base, "index.tsx"), join(base, "index.ts")]) {
200
+ if (existsSync(c) && statSync(c).isFile()) return c;
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /** Where `name` comes from when it is imported or re-exported by `file`. */
206
+ function importOf(file, name) {
207
+ const src = read(file);
208
+ for (const m of src.matchAll(/\b(?:import|export)\s+(?:type\s+)?([\s\S]*?)\s+from\s+["']([^"']+)["']/g)) {
209
+ const clause = m[1];
210
+ const spec = m[2];
211
+ const braces = clause.match(/\{([\s\S]*)\}/);
212
+ if (braces) {
213
+ for (const part of braces[1].split(",")) {
214
+ const [orig, alias] = part.replace(/^\s*type\s+/, "").split(/\s+as\s+/).map((s) => s.trim());
215
+ if ((alias ?? orig) === name && orig) return { file: resolveFile(file, spec), name: orig };
216
+ }
217
+ }
218
+ const def = clause.replace(/\{[\s\S]*\}/, "").replace(/,/g, " ").trim();
219
+ if (def && !def.startsWith("*") && def.split(/\s+/)[0] === name && m[0].startsWith("import")) return { file: resolveFile(file, spec), name: "default" };
220
+ }
221
+ return null;
222
+ }
223
+
224
+ const DEF = (name) =>
225
+ name === "default"
226
+ ? /(^|\n)export\s+default\s+(?:async\s+)?(?:function\b[^(]*|class\b|)/
227
+ : new RegExp(`(^|\\n)(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function\\s+${name}\\s*[<(]|(?:const|let)\\s+${name}\\b[^=\\n]*=)`);
228
+ const NEXT_DEF = /\n(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|const|let|class|interface|type|enum)\s/g;
229
+
230
+ /** The source of `name`'s definition in `file`, to the next top-level declaration. */
231
+ function bodyOf(file, name) {
232
+ const src = read(file);
233
+ const m = src.match(DEF(name));
234
+ if (!m) {
235
+ if (name === "default") {
236
+ const d = src.match(/\nexport\s+default\s+([A-Za-z_$][\w$]*)\s*;?/);
237
+ if (d) return bodyOf(file, d[1]);
238
+ }
239
+ return null;
240
+ }
241
+ const start = m.index + m[1].length;
242
+ NEXT_DEF.lastIndex = start + 1;
243
+ const next = NEXT_DEF.exec(src);
244
+ return src.slice(start, next ? next.index : src.length);
245
+ }
246
+
247
+ /** Does rendering `name` (defined in or imported by `file`) publish a crumb? null: cannot tell. */
248
+ function publishes(file, name, depth = 0, seen = new Set()) {
249
+ if (!file) return null;
250
+ const key = `${file}#${name}`;
251
+ if (seen.has(key)) return false;
252
+ seen.add(key);
253
+ const body = bodyOf(file, name);
254
+ if (body === null) {
255
+ const imp = importOf(file, name);
256
+ return imp ? publishes(imp.file, imp.name, depth, seen) : null;
257
+ }
258
+ if (PUBLISHES.test(body)) return true;
259
+ // lazy(() => import("./x")) or import("./x").then((m) => ({ default: m.X }))
260
+ const lazy = body.match(/import\(\s*["']([^"']+)["']\s*\)(?:[\s\S]*?\bm\.([A-Za-z_$][\w$]*))?/);
261
+ if (lazy) {
262
+ const target = resolveFile(file, lazy[1]);
263
+ if (publishes(target, lazy[2] ?? "default", depth + 1, seen)) return true;
264
+ }
265
+ if (depth >= MAX_DEPTH) return false;
266
+ const next = new Set([...body.matchAll(/<([A-Z][\w$]*)/g), ...body.matchAll(/\b(use[A-Z][\w$]*)\s*\(/g)].map((x) => x[1]));
267
+ next.delete(name);
268
+ for (const n of next) if (publishes(file, n, depth + 1, seen)) return true;
269
+ return false;
270
+ }
271
+
272
+ /** A component that only ever redirects (it renders <Navigate> and at most a loading state). */
273
+ const LOADING = new Set(["Navigate", "Splash", "Spinner", "Skeleton", "Loading"]);
274
+ function redirectOnly(file, name, seen = new Set()) {
275
+ if (!file || seen.has(`${file}#${name}`)) return false;
276
+ seen.add(`${file}#${name}`);
277
+ const body = bodyOf(file, name);
278
+ if (body === null) {
279
+ const imp = importOf(file, name);
280
+ return imp ? redirectOnly(imp.file, imp.name, seen) : false;
281
+ }
282
+ const tags = componentsIn(body);
283
+ return tags.includes("Navigate") && tags.every((t) => LOADING.has(t));
284
+ }
285
+
286
+ const { ignore, dirs } = parseArgs(process.argv.slice(2));
287
+ const ROOT = process.cwd();
288
+ const misses = [];
289
+ let routes = 0;
290
+ for (const dir of dirs) {
291
+ const abs = resolve(ROOT, dir);
292
+ if (!existsSync(abs)) {
293
+ console.error(`entity-crumbs: ${dir} does not exist`);
294
+ process.exit(2);
295
+ }
296
+ for (const file of collect(abs)) {
297
+ const src = read(file);
298
+ for (const r of routesIn(src)) {
299
+ if (r.layout || r.elements.length === 0) continue;
300
+ const params = entityParams(r.path, ignore);
301
+ if (params.length === 0) continue;
302
+ if (r.elements.some((e) => e === "Navigate" || /Redirect$/.test(e) || redirectOnly(file, e))) continue;
303
+ if (silenced(src, r.at)) continue;
304
+ routes++;
305
+ const answers = [...r.elements, ...r.parents].map((e) => publishes(file, e));
306
+ // Unknown (an element from a package) never fails a route; only a screen we could read all of does.
307
+ if (answers.some((a) => a === true) || answers.some((a) => a === null)) continue;
308
+ misses.push(`${relative(ROOT, file)}:${lineOf(src, r.at)} "${r.path}" renders <${r.elements.join("> <")}>, which never calls useCrumb/useCrumbs, renders EntityScreen or calls useActiveContext`);
309
+ }
310
+ }
311
+ }
312
+
313
+ if (misses.length) {
314
+ console.error(`entity-crumbs: ${misses.length} route(s) with :${"param"} render a screen that names no entity (NAV-CONTRACT §4):\n`);
315
+ for (const m of misses) console.error(` ${m}`);
316
+ console.error(`\nCall useCrumb(entity.name) in the screen, or mark the route "// entity-crumb: ok — <why>".`);
317
+ process.exit(1);
318
+ }
319
+ console.log(`entity-crumbs: clean (${routes} entity route(s) checked)`);