@cosmicdrift/kumiko-guards 0.306.0 → 0.308.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.
|
|
3
|
+
"version": "0.308.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.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.308.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.308.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "New guard-no-framed-extension-sections flags a registered extension-section component that renders its own Card/SectionCard/CollapsibleSection (fw#3234)",
|
|
6
|
+
"detail": "Modeled on guard-no-custom-primitives: scans packages/bundled-features/src/** and samples/** for `extensionSectionComponents: { ... }` registrations, then checks each registered component's own render body for a nested Card/SectionCard/CollapsibleSection — the host (RenderEdit) already frames it in one card, so a second one doubles the border/padding. Registered in run-ui-guards.ts. Per-line override: `// kumiko-lint-ignore no-framed-extension-sections <reason>`.",
|
|
7
|
+
"migration": "New rule, no baseline — the measured backlog across bundled-features and samples is 0 (notes-section.tsx's own nested cards were dropped as part of this change)."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.301.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Extension-section components (registered via a ClientFeatureDefinition's
|
|
3
|
+
// `extensionSectionComponents`) are mounted inside RenderEdit's own section
|
|
4
|
+
// frame (Section in standalone screens, Card in tabs mode — see
|
|
5
|
+
// render-edit.tsx). A registered component that renders its own
|
|
6
|
+
// Card/SectionCard/CollapsibleSection nests a second card inside that frame
|
|
7
|
+
// — double border/padding, exactly what the projectionDetail/entityEdit-tabs
|
|
8
|
+
// unification (framework issue about single-card section framing) removes.
|
|
9
|
+
//
|
|
10
|
+
// Scope: only components actually referenced as a value in an
|
|
11
|
+
// `extensionSectionComponents: { ... }` object literal — a component that is
|
|
12
|
+
// ALSO mountable standalone (its own doc/comment says so) keeps its own
|
|
13
|
+
// card chrome fine in that mode; this guard only catches it nesting a card
|
|
14
|
+
// INSIDE the extension-section render path.
|
|
15
|
+
//
|
|
16
|
+
// Resolution: the registered value is almost never declared in the same
|
|
17
|
+
// file as the registration (client-app.tsx imports it, often through a
|
|
18
|
+
// feature-folder barrel). We follow named/aliased/shorthand imports and
|
|
19
|
+
// `export { X } from "./y"` / `export * from "./y"` barrel re-exports
|
|
20
|
+
// across files (bounded by a visited-set + hop limit) to find the real
|
|
21
|
+
// JSX body, and we do the same one hop further for any capitalised JSX
|
|
22
|
+
// child the resolved body renders (a registered component that itself has
|
|
23
|
+
// no Card but delegates to a child component that does still double-frames).
|
|
24
|
+
// A module specifier that does not resolve to a project source file (a real
|
|
25
|
+
// external package import, e.g. `@cosmicdrift/kumiko-bundled-features/...`)
|
|
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.
|
|
29
|
+
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
32
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
33
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
34
|
+
|
|
35
|
+
const ROOT = process.cwd();
|
|
36
|
+
|
|
37
|
+
const SCAN: ScanSpec = {
|
|
38
|
+
scope: "source",
|
|
39
|
+
extensions: ["tsx"],
|
|
40
|
+
frameworkWithin: ["packages/bundled-features/src/**", "samples/**"],
|
|
41
|
+
};
|
|
42
|
+
const EXCLUDE = /(__tests__|\.test\.tsx?$|\.integration\.tsx?$|\.d\.ts$)/;
|
|
43
|
+
const IGNORE_TAG = "kumiko-lint-ignore no-framed-extension-sections";
|
|
44
|
+
const MAX_HOPS = 4;
|
|
45
|
+
|
|
46
|
+
const FRAMED_TAG = /^(Card|SectionCard|CollapsibleSection)$/;
|
|
47
|
+
|
|
48
|
+
// A vendored package's shipped .tsx source is still resolvable through
|
|
49
|
+
// node_modules — treat it the same as a truly unresolvable specifier
|
|
50
|
+
// ("package component, checked inside the package itself"), not as a hop
|
|
51
|
+
// into the App-Repo's own extension code.
|
|
52
|
+
function isExternalSourceFile(sf: SourceFile): boolean {
|
|
53
|
+
return sf.getFilePath().includes("/node_modules/");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findExtensionComponentNames(sf: SourceFile): Set<string> {
|
|
57
|
+
const names = new Set<string>();
|
|
58
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
59
|
+
if (prop.getName() !== "extensionSectionComponents") continue;
|
|
60
|
+
const init = prop.getInitializer();
|
|
61
|
+
if (init === undefined || init.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
62
|
+
for (const member of init.asKindOrThrow(SyntaxKind.ObjectLiteralExpression).getProperties()) {
|
|
63
|
+
// { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
|
|
64
|
+
// possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
|
|
65
|
+
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
|
+
}
|
|
70
|
+
} else if (member.getKind() === SyntaxKind.ShorthandPropertyAssignment) {
|
|
71
|
+
names.add(member.asKindOrThrow(SyntaxKind.ShorthandPropertyAssignment).getName());
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return names;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findLocalDeclaration(sf: SourceFile, name: string): Node[] {
|
|
79
|
+
const fn = sf.getFunction(name);
|
|
80
|
+
if (fn !== undefined) return [fn];
|
|
81
|
+
const v = sf.getVariableDeclaration(name);
|
|
82
|
+
if (v === undefined) return [];
|
|
83
|
+
const init = v.getInitializer();
|
|
84
|
+
return init !== undefined ? [init] : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Resolved = { readonly sf: SourceFile; readonly nodes: readonly Node[] };
|
|
88
|
+
|
|
89
|
+
/** Resolves `exportedName` inside `sf`: a local declaration, or one hop
|
|
90
|
+
* through a barrel re-export (`export { X } from "./y"` / `export * from
|
|
91
|
+
* "./y"`). Returns undefined for an external (unresolvable) module or a
|
|
92
|
+
* dead end. */
|
|
93
|
+
function resolveExported(
|
|
94
|
+
sf: SourceFile,
|
|
95
|
+
exportedName: string,
|
|
96
|
+
visited: Set<string>,
|
|
97
|
+
hops: number,
|
|
98
|
+
): Resolved | "external" | undefined {
|
|
99
|
+
const key = `${sf.getFilePath()}::export::${exportedName}`;
|
|
100
|
+
if (visited.has(key) || hops > MAX_HOPS) return undefined;
|
|
101
|
+
visited.add(key);
|
|
102
|
+
|
|
103
|
+
const local = findLocalDeclaration(sf, exportedName);
|
|
104
|
+
if (local.length > 0) return { sf, nodes: local };
|
|
105
|
+
|
|
106
|
+
for (const exp of sf.getExportDeclarations()) {
|
|
107
|
+
const targetSf = exp.getModuleSpecifierSourceFile();
|
|
108
|
+
const namedExports = exp.getNamedExports();
|
|
109
|
+
if (namedExports.length === 0) {
|
|
110
|
+
// `export * from "./y"` — re-check the same name one module over.
|
|
111
|
+
if (targetSf === undefined) continue;
|
|
112
|
+
if (isExternalSourceFile(targetSf)) return "external";
|
|
113
|
+
const res = resolveExported(targetSf, exportedName, visited, hops + 1);
|
|
114
|
+
if (res !== undefined) return res;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const named of namedExports) {
|
|
118
|
+
const asName = named.getAliasNode()?.getText() ?? named.getName();
|
|
119
|
+
if (asName !== exportedName) continue;
|
|
120
|
+
if (targetSf === undefined || isExternalSourceFile(targetSf)) return "external";
|
|
121
|
+
return resolveExported(targetSf, named.getName(), visited, hops + 1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolves `name` used inside `sf` (registration value or a JSX child tag)
|
|
128
|
+
* to its declaring file + body, following imports and barrels. */
|
|
129
|
+
function resolveComponent(
|
|
130
|
+
sf: SourceFile,
|
|
131
|
+
name: string,
|
|
132
|
+
visited: Set<string>,
|
|
133
|
+
hops: number,
|
|
134
|
+
): Resolved | "external" | undefined {
|
|
135
|
+
const local = findLocalDeclaration(sf, name);
|
|
136
|
+
if (local.length > 0) return { sf, nodes: local };
|
|
137
|
+
if (hops > MAX_HOPS) return undefined;
|
|
138
|
+
|
|
139
|
+
for (const imp of sf.getImportDeclarations()) {
|
|
140
|
+
const defaultImport = imp.getDefaultImport();
|
|
141
|
+
if (defaultImport !== undefined && defaultImport.getText() === name) {
|
|
142
|
+
const targetSf = imp.getModuleSpecifierSourceFile();
|
|
143
|
+
if (targetSf === undefined || isExternalSourceFile(targetSf)) return "external";
|
|
144
|
+
return resolveExported(targetSf, "default", visited, hops + 1);
|
|
145
|
+
}
|
|
146
|
+
for (const named of imp.getNamedImports()) {
|
|
147
|
+
const localName = named.getAliasNode()?.getText() ?? named.getName();
|
|
148
|
+
if (localName !== name) continue;
|
|
149
|
+
const targetSf = imp.getModuleSpecifierSourceFile();
|
|
150
|
+
if (targetSf === undefined || isExternalSourceFile(targetSf)) return "external";
|
|
151
|
+
return resolveExported(targetSf, named.getName(), visited, hops + 1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function jsxElements(nodes: readonly Node[]): Node[] {
|
|
158
|
+
return nodes.flatMap((root) => [
|
|
159
|
+
...root.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
|
|
160
|
+
...root.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type TaggedElement = { readonly el: Node; readonly tag: string };
|
|
165
|
+
|
|
166
|
+
function tagNameOf(el: Node): string {
|
|
167
|
+
if (Node.isJsxOpeningElement(el) || Node.isJsxSelfClosingElement(el)) {
|
|
168
|
+
return el.getTagNameNode().getText();
|
|
169
|
+
}
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function taggedElements(nodes: readonly Node[]): TaggedElement[] {
|
|
174
|
+
return jsxElements(nodes).map((el) => ({ el, tag: tagNameOf(el) }));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function collectFramedViolations(
|
|
178
|
+
registeredName: string,
|
|
179
|
+
resolved: Resolved,
|
|
180
|
+
chain: readonly string[],
|
|
181
|
+
visited: Set<string>,
|
|
182
|
+
hops: number,
|
|
183
|
+
): GuardViolation[] {
|
|
184
|
+
const violations: GuardViolation[] = [];
|
|
185
|
+
for (const { el, tag } of taggedElements(resolved.nodes)) {
|
|
186
|
+
if (FRAMED_TAG.test(tag)) {
|
|
187
|
+
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
|
188
|
+
const via = chain.length > 1 ? ` (via ${chain.join(" -> ")})` : "";
|
|
189
|
+
violations.push({
|
|
190
|
+
file: path.relative(ROOT, resolved.sf.getFilePath()),
|
|
191
|
+
line: el.getStartLineNumber(),
|
|
192
|
+
message: `Extension-section component "${registeredName}"${via} renders its own <${tag}> — the host already frames it in one card. Drop it (plain container / Heading), or if genuinely needed: // ${IGNORE_TAG} <reason>`,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (hops >= MAX_HOPS || !/^[A-Z]/.test(tag)) continue;
|
|
197
|
+
const child = resolveComponent(resolved.sf, tag, visited, hops + 1);
|
|
198
|
+
if (child === undefined || child === "external") continue;
|
|
199
|
+
violations.push(
|
|
200
|
+
...collectFramedViolations(registeredName, child, [...chain, tag], visited, hops + 1),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
return violations;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function analyse(files: readonly SourceFile[]): {
|
|
207
|
+
violations: GuardViolation[];
|
|
208
|
+
} {
|
|
209
|
+
const violations: GuardViolation[] = [];
|
|
210
|
+
for (const sf of files) {
|
|
211
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
212
|
+
const registeredNames = findExtensionComponentNames(sf);
|
|
213
|
+
if (registeredNames.size === 0) continue;
|
|
214
|
+
for (const name of registeredNames) {
|
|
215
|
+
const visited = new Set<string>();
|
|
216
|
+
const resolved = resolveComponent(sf, name, visited, 0);
|
|
217
|
+
if (resolved === undefined || resolved === "external") continue;
|
|
218
|
+
violations.push(...collectFramedViolations(name, resolved, [name], visited, 0));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { violations };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const guard: AstGuard = {
|
|
225
|
+
name: "No-Framed-Extension-Sections Guard",
|
|
226
|
+
scan: SCAN,
|
|
227
|
+
hint:
|
|
228
|
+
"An extension-section component registered via extensionSectionComponents is mounted inside the host's own Section/Card frame — it must not render a second Card/SectionCard/CollapsibleSection around its own content, directly or through a delegated child component. " +
|
|
229
|
+
`Real exception: // ${IGNORE_TAG} <reason>`,
|
|
230
|
+
run: (files) => analyse(files),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (import.meta.main) {
|
|
234
|
+
runStandalone(guard);
|
|
235
|
+
}
|
package/src/run-ui-guards.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "./_lib/guard-kit";
|
|
11
11
|
import { guard as i18nUiStrings } from "./guard-i18n-ui-strings";
|
|
12
12
|
import { guard as noCustomPrimitives } from "./guard-no-custom-primitives";
|
|
13
|
+
import { guard as noFramedExtensionSections } from "./guard-no-framed-extension-sections";
|
|
13
14
|
import { guard as noInlineStyles } from "./guard-no-inline-styles";
|
|
14
15
|
import { guard as noRawHooks } from "./guard-no-raw-hooks";
|
|
15
16
|
import { guard as rawClassname } from "./guard-raw-classname";
|
|
@@ -20,6 +21,7 @@ export const UI_GUARDS = [
|
|
|
20
21
|
rawClassname,
|
|
21
22
|
noInlineStyles,
|
|
22
23
|
noCustomPrimitives,
|
|
24
|
+
noFramedExtensionSections,
|
|
23
25
|
noRawHooks,
|
|
24
26
|
tailwindScanSurface,
|
|
25
27
|
rawInteractiveElements,
|