@cosmicdrift/kumiko-guards 0.316.0 → 0.318.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.316.0",
3
+ "version": "0.318.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.316.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.318.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.317.0",
4
+ "type": "fix",
5
+ "title": "no-framed-extension-sections now resolves the actual mount kind before flagging",
6
+ "detail": "extensionSectionComponents backs three different mount shapes: a\nkind: \"extension\" section (framed by the host's own Card/Section), a\ndashboard kind: \"custom\" panel, and an entityEdit slots.header slot. Only\nthe section mount is actually framed, but the guard previously flagged\nevery registered component that rendered its own Card/SectionCard/\nCollapsibleSection regardless of which of the three ways it was mounted,\nproducing false positives for components that only ever render as a bare\ndashboard panel or header slot.\nThe guard now scans every __component: SOME_CONST usage site across the\nscanned files (extending its scan to plain .ts files, not just .tsx, so\nscreens.ts/feature.ts usage sites are actually seen), classifies each by\nits enclosing mount kind, and correlates it back to the registration via\nthe shared registry key both sides reference by name. A component is only\nexempted when every usage site resolves to a custom panel and/or a header\nslot; a component used as a section anywhere, or whose usage sites cannot\nbe classified at all, keeps today's conservative \"stays flagged\"\nbehavior. List-header-slot mounts and footer/titleAction slots also keep\nthe conservative behavior since this fix only covers the two shapes that\nproduced real false positives.\nThe scan now also covers plain .ts files on both the registration and the\nusage side, not just .tsx, so a registration declared inside a\nscreens.ts/feature.ts file is checked for the first time. Checked against\npublicstatus and solon, the two consumers currently registering\nextensionSectionComponents from a .ts file: the fix does not add findings\nthere, since their one Card-rendering registration is mounted exclusively\nas a kind: \"custom\" panel, which this guard now correctly exempts."
7
+ },
2
8
  {
3
9
  "version": "0.314.0",
4
10
  "type": "fix",
@@ -24,11 +24,17 @@
24
24
  // A module specifier that does not resolve to a project source file (a real
25
25
  // external package import, e.g. `@cosmicdrift/kumiko-bundled-features/...`)
26
26
  // is treated as "package component, already checked inside the package
27
- // itself" and is not flagged here — the package's own source is in scope
28
- // via `frameworkWithin` when this guard runs against the framework repo.
27
+ // itself" and is not flagged here, since the package's own source is in
28
+ // scope via `frameworkWithin` when this guard runs against the framework repo.
29
+ //
30
+ // Mount-kind scoping: the same registry also backs `kind: "custom"`
31
+ // dashboard panels and `slots: { header: {...} }` entityEdit slots, both of
32
+ // which render bare. The guard classifies every `__component` usage site by
33
+ // mount kind and only exempts a component used exclusively as one of those
34
+ // two; no usage site found at all, or any other mount kind, stays flagged.
29
35
 
30
36
  import * as path from "node:path";
31
- import { Node, type SourceFile, SyntaxKind } from "ts-morph";
37
+ import { Node, type ObjectLiteralExpression, type SourceFile, SyntaxKind } from "ts-morph";
32
38
  import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
33
39
  import { hasIgnoreTag } from "./_lib/ignore-tag";
34
40
 
@@ -36,7 +42,7 @@ const ROOT = process.cwd();
36
42
 
37
43
  const SCAN: ScanSpec = {
38
44
  scope: "source",
39
- extensions: ["tsx"],
45
+ extensions: ["ts", "tsx"],
40
46
  frameworkWithin: ["packages/bundled-features/src/**", "samples/**"],
41
47
  };
42
48
  const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
@@ -53,8 +59,9 @@ function isExternalSourceFile(sf: SourceFile): boolean {
53
59
  return sf.getFilePath().includes("/node_modules/");
54
60
  }
55
61
 
56
- function findExtensionComponentNames(sf: SourceFile): Set<string> {
57
- const names = new Set<string>();
62
+ /** Maps each registered component's identifier to the registry key usage sites reference via `__component: SOME_CONST`. */
63
+ function findExtensionComponentEntries(sf: SourceFile): Map<string, string> {
64
+ const entries = new Map<string, string>();
58
65
  for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
59
66
  if (prop.getName() !== "extensionSectionComponents") continue;
60
67
  const init = prop.getInitializer();
@@ -63,16 +70,23 @@ function findExtensionComponentNames(sf: SourceFile): Set<string> {
63
70
  // { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
64
71
  // possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
65
72
  if (member.getKind() === SyntaxKind.PropertyAssignment) {
66
- const valueInit = member.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
67
- if (valueInit !== undefined && valueInit.getKind() === SyntaxKind.Identifier) {
68
- names.add(valueInit.getText());
69
- }
73
+ const pa = member.asKindOrThrow(SyntaxKind.PropertyAssignment);
74
+ const valueInit = pa.getInitializer();
75
+ if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
76
+ const nameNode = pa.getNameNode();
77
+ const key = Node.isComputedPropertyName(nameNode)
78
+ ? nameNode.getExpression().getText()
79
+ : Node.isStringLiteral(nameNode)
80
+ ? nameNode.getLiteralValue()
81
+ : pa.getName();
82
+ entries.set(valueInit.getText(), key);
70
83
  } else if (member.getKind() === SyntaxKind.ShorthandPropertyAssignment) {
71
- names.add(member.asKindOrThrow(SyntaxKind.ShorthandPropertyAssignment).getName());
84
+ const name = member.asKindOrThrow(SyntaxKind.ShorthandPropertyAssignment).getName();
85
+ entries.set(name, name);
72
86
  }
73
87
  }
74
88
  }
75
- return names;
89
+ return entries;
76
90
  }
77
91
 
78
92
  function findLocalDeclaration(sf: SourceFile, name: string): Node[] {
@@ -203,18 +217,87 @@ function collectFramedViolations(
203
217
  return violations;
204
218
  }
205
219
 
220
+ type MountKind = "extension" | "custom" | "header" | "other";
221
+
222
+ function findAncestorObjectLiterals(node: Node): ObjectLiteralExpression[] {
223
+ return node.getAncestors().filter(Node.isObjectLiteralExpression);
224
+ }
225
+
226
+ function stringPropertyValue(obj: ObjectLiteralExpression, propName: string): string | undefined {
227
+ const prop = obj.getProperty(propName);
228
+ if (prop === undefined || !Node.isPropertyAssignment(prop)) return undefined;
229
+ const init = prop.getInitializer();
230
+ return init !== undefined && Node.isStringLiteral(init) ? init.getLiteralValue() : undefined;
231
+ }
232
+
233
+ function isDirectlyUnderSlotsHeader(obj: ObjectLiteralExpression): boolean {
234
+ const headerAssignment = obj.getParentIfKind(SyntaxKind.PropertyAssignment);
235
+ if (headerAssignment === undefined || headerAssignment.getName() !== "header") return false;
236
+ const slotsObject = headerAssignment.getFirstAncestorByKind(SyntaxKind.ObjectLiteralExpression);
237
+ if (slotsObject === undefined) return false;
238
+ const slotsAssignment = slotsObject.getParentIfKind(SyntaxKind.PropertyAssignment);
239
+ return slotsAssignment !== undefined && slotsAssignment.getName() === "slots";
240
+ }
241
+
242
+ function mountKindFor(componentPropertyAssignment: Node): MountKind {
243
+ for (const obj of findAncestorObjectLiterals(componentPropertyAssignment)) {
244
+ const kind = stringPropertyValue(obj, "kind");
245
+ if (kind === "extension") return "extension";
246
+ if (kind === "custom") return "custom";
247
+ if (isDirectlyUnderSlotsHeader(obj)) return "header";
248
+ }
249
+ return "other";
250
+ }
251
+
252
+ function findComponentNameUsages(sf: SourceFile): { name: string; kind: MountKind }[] {
253
+ const usages: { name: string; kind: MountKind }[] = [];
254
+ for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
255
+ if (prop.getName() !== "__component") continue;
256
+ const init = prop.getInitializer();
257
+ if (init === undefined) continue;
258
+ const name = Node.isStringLiteral(init)
259
+ ? init.getLiteralValue()
260
+ : Node.isIdentifier(init)
261
+ ? init.getText()
262
+ : undefined;
263
+ if (name === undefined) continue;
264
+ usages.push({ name, kind: mountKindFor(prop) });
265
+ }
266
+ return usages;
267
+ }
268
+
269
+ function buildUsageKindsByName(files: readonly SourceFile[]): Map<string, Set<MountKind>> {
270
+ const byName = new Map<string, Set<MountKind>>();
271
+ for (const sf of files) {
272
+ for (const { name, kind } of findComponentNameUsages(sf)) {
273
+ const kinds = byName.get(name) ?? new Set<MountKind>();
274
+ kinds.add(kind);
275
+ byName.set(name, kinds);
276
+ }
277
+ }
278
+ return byName;
279
+ }
280
+
281
+ /** Exempt only when every known usage is a custom panel and/or a header slot. */
282
+ function stillNeedsFraming(kinds: Set<MountKind> | undefined): boolean {
283
+ if (kinds === undefined) return true;
284
+ return [...kinds].some((kind) => kind !== "custom" && kind !== "header");
285
+ }
286
+
206
287
  function analyse(files: readonly SourceFile[]): {
207
288
  violations: GuardViolation[];
208
289
  } {
209
290
  const violations: GuardViolation[] = [];
291
+ const usageKindsByName = buildUsageKindsByName(files);
210
292
  for (const sf of files) {
211
293
  if (EXCLUDE.test(sf.getFilePath())) continue;
212
- const registeredNames = findExtensionComponentNames(sf);
213
- if (registeredNames.size === 0) continue;
214
- for (const name of registeredNames) {
294
+ const registeredEntries = findExtensionComponentEntries(sf);
295
+ if (registeredEntries.size === 0) continue;
296
+ for (const [name, registryKey] of registeredEntries.entries()) {
215
297
  const visited = new Set<string>();
216
298
  const resolved = resolveComponent(sf, name, visited, 0);
217
299
  if (resolved === undefined || resolved === "external") continue;
300
+ if (!stillNeedsFraming(usageKindsByName.get(registryKey))) continue;
218
301
  violations.push(...collectFramedViolations(name, resolved, [name], visited, 0));
219
302
  }
220
303
  }