@cosmicdrift/kumiko-guards 0.330.2 → 0.332.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 +2 -2
- package/src/_lib/git-env.ts +27 -1
- package/src/_lib/guard-kit.ts +3 -2
- package/src/_lib/scan-scope.ts +14 -4
- package/src/_lib/security-baseline.ts +6 -2
- package/src/_lib/test-file.ts +2 -0
- package/src/changes.json +51 -0
- package/src/check-real-provider-isolation.ts +45 -11
- package/src/check-secret-literals.ts +95 -20
- package/src/guard-access-denied-test.ts +4 -4
- package/src/guard-admin-api.ts +11 -5
- package/src/guard-direct-entity-writes.ts +37 -1
- package/src/guard-direct-fetch.ts +4 -7
- package/src/guard-escape-hatch-declared.ts +2 -1
- package/src/guard-feature-integration-tests.ts +10 -9
- package/src/guard-html-escape.ts +76 -19
- package/src/guard-lib-test-coverage.ts +10 -3
- package/src/guard-loadall-events.ts +8 -2
- package/src/guard-no-direct-process-env.ts +2 -1
- package/src/guard-no-framed-extension-sections.ts +5 -1
- package/src/guard-pii-annotations.ts +5 -4
- package/src/guard-primitives-discipline.ts +52 -20
- package/src/guard-raw-interactive-elements.ts +25 -15
- package/src/guard-tailwind-scan-surface.ts +39 -28
- package/src/guard-tenant-escalation.ts +7 -8
- package/src/guard-test-stack-drift.ts +15 -2
- package/src/guard-text-field-stance.ts +4 -3
- package/src/guard-thin-wrappers.ts +2 -3
- package/src/index.ts +1 -0
- package/src/pre-push.sh +26 -4
package/src/guard-html-escape.ts
CHANGED
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
|
|
33
33
|
import * as path from "node:path";
|
|
34
34
|
import {
|
|
35
|
+
type ArrowFunction,
|
|
36
|
+
type FunctionDeclaration,
|
|
37
|
+
type FunctionExpression,
|
|
35
38
|
type Identifier,
|
|
36
39
|
Node,
|
|
37
40
|
type SourceFile,
|
|
@@ -155,12 +158,60 @@ function isSafeLocalIdentifier(ident: Identifier, sf: SourceFile, depth: number)
|
|
|
155
158
|
});
|
|
156
159
|
}
|
|
157
160
|
|
|
158
|
-
function
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
function isSafeTemplateTag(tag: Node): boolean {
|
|
162
|
+
const name = tag.isKind(SyntaxKind.PropertyAccessExpression)
|
|
163
|
+
? tag.getNameNode().getText()
|
|
164
|
+
: tag.getText();
|
|
165
|
+
return SAFE_TEMPLATE_TAGS.has(name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
type LocalFunction = FunctionDeclaration | ArrowFunction | FunctionExpression;
|
|
169
|
+
|
|
170
|
+
function localFunctionsOf(callee: Node, sf: SourceFile): LocalFunction[] | undefined {
|
|
171
|
+
if (!callee.isKind(SyntaxKind.Identifier)) return undefined;
|
|
172
|
+
const decls = localDeclarations(callee as Identifier, sf);
|
|
173
|
+
if (decls.length === 0) return undefined;
|
|
174
|
+
const fns: LocalFunction[] = [];
|
|
175
|
+
for (const d of decls) {
|
|
176
|
+
if (d.isKind(SyntaxKind.FunctionDeclaration)) {
|
|
177
|
+
fns.push(d);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const init = d.isKind(SyntaxKind.VariableDeclaration) ? d.getInitializer() : undefined;
|
|
181
|
+
if (init?.isKind(SyntaxKind.ArrowFunction) || init?.isKind(SyntaxKind.FunctionExpression)) {
|
|
182
|
+
fns.push(init);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return fns;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function returnExpressionsOf(fn: LocalFunction): Node[] | undefined {
|
|
191
|
+
const body = fn.getBody();
|
|
192
|
+
if (!body) return undefined;
|
|
193
|
+
if (!Node.isBlock(body)) return [body];
|
|
194
|
+
const out: Node[] = [];
|
|
195
|
+
for (const ret of body.getDescendantsOfKind(SyntaxKind.ReturnStatement)) {
|
|
196
|
+
// Returns of nested functions belong to those, not to `fn`.
|
|
197
|
+
if (ret.getFirstAncestor((a) => Node.isFunctionLikeDeclaration(a)) !== fn) continue;
|
|
198
|
+
const expr = ret.getExpression();
|
|
199
|
+
if (expr) out.push(expr);
|
|
162
200
|
}
|
|
163
|
-
return
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A call to a directly named local function is trusted only when everything
|
|
205
|
+
// it returns is itself safe — `function render(x) { return x; }` is not.
|
|
206
|
+
// Methods on classes/objects/namespaces (`Templates.render(...)`) are never
|
|
207
|
+
// trusted by their root alone.
|
|
208
|
+
function isSafeLocalCall(callee: Node, sf: SourceFile, depth: number): boolean {
|
|
209
|
+
const fns = localFunctionsOf(callee, sf);
|
|
210
|
+
if (!fns) return false;
|
|
211
|
+
return fns.every((fn) => {
|
|
212
|
+
const returns = returnExpressionsOf(fn);
|
|
213
|
+
return returns?.every((r) => isSafeExpression(r, sf, depth + 1)) === true;
|
|
214
|
+
});
|
|
164
215
|
}
|
|
165
216
|
|
|
166
217
|
function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
@@ -176,14 +227,17 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
|
176
227
|
) {
|
|
177
228
|
return true;
|
|
178
229
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return
|
|
230
|
+
if (expr.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) return true;
|
|
231
|
+
// A template with an HTML tag is scanned as its own candidate (incl. its
|
|
232
|
+
// html-ok comments). One without is never scanned, so its interpolations
|
|
233
|
+
// must be safe here.
|
|
234
|
+
if (expr.isKind(SyntaxKind.TemplateExpression)) {
|
|
235
|
+
if (HTML_TAG.test(templateStaticText(expr))) return true;
|
|
236
|
+
return expr
|
|
237
|
+
.getTemplateSpans()
|
|
238
|
+
.every((span) => isSafeExpression(span.getExpression(), sf, depth + 1));
|
|
186
239
|
}
|
|
240
|
+
if (expr.isKind(SyntaxKind.TaggedTemplateExpression)) return isSafeTemplateTag(expr.getTag());
|
|
187
241
|
if (expr.isKind(SyntaxKind.ConditionalExpression)) {
|
|
188
242
|
return (
|
|
189
243
|
isSafeExpression(expr.getWhenTrue(), sf, depth + 1) &&
|
|
@@ -202,6 +256,14 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
|
202
256
|
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
203
257
|
);
|
|
204
258
|
}
|
|
259
|
+
// String concatenation is safe when both operands are.
|
|
260
|
+
if (
|
|
261
|
+
op === SyntaxKind.PlusToken &&
|
|
262
|
+
isSafeExpression(expr.getLeft(), sf, depth + 1) &&
|
|
263
|
+
isSafeExpression(expr.getRight(), sf, depth + 1)
|
|
264
|
+
) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
205
267
|
// Arithmetic (`${width / 2}`) is safe as long as the result type is number.
|
|
206
268
|
return isCompileTimeKnownType(expr.getType());
|
|
207
269
|
}
|
|
@@ -244,8 +306,7 @@ function isSafeExpression(expr: Node, sf: SourceFile, depth: number): boolean {
|
|
|
244
306
|
}
|
|
245
307
|
}
|
|
246
308
|
if (endsWithHtmlConvention(callee)) return true;
|
|
247
|
-
|
|
248
|
-
if (root && isSafeLocalIdentifier(root, sf, depth)) return true;
|
|
309
|
+
if (isSafeLocalCall(callee, sf, depth)) return true;
|
|
249
310
|
return isCompileTimeKnownType(expr.getType());
|
|
250
311
|
}
|
|
251
312
|
if (expr.isKind(SyntaxKind.Identifier)) {
|
|
@@ -280,11 +341,7 @@ function isInsideErrorConstruction(tpl: Node): boolean {
|
|
|
280
341
|
function isInsideSafeTag(tpl: Node): boolean {
|
|
281
342
|
const parent = tpl.getParent();
|
|
282
343
|
if (!parent?.isKind(SyntaxKind.TaggedTemplateExpression)) return false;
|
|
283
|
-
|
|
284
|
-
const name = tag.isKind(SyntaxKind.PropertyAccessExpression)
|
|
285
|
-
? tag.getNameNode().getText()
|
|
286
|
-
: tag.getText();
|
|
287
|
-
return SAFE_TEMPLATE_TAGS.has(name);
|
|
344
|
+
return isSafeTemplateTag(parent.getTag());
|
|
288
345
|
}
|
|
289
346
|
|
|
290
347
|
function hasHtmlOkComment(lines: readonly string[], line: number): boolean {
|
|
@@ -36,14 +36,20 @@ const SCAN: ScanSpec = {
|
|
|
36
36
|
scope: "source",
|
|
37
37
|
extensions: ["ts", "tsx"],
|
|
38
38
|
kinds: ["library", "app"],
|
|
39
|
-
within: ["lib/**/*.ts", "
|
|
39
|
+
within: ["lib/**/*.{ts,tsx}", "**/lib/**/*.{ts,tsx}", "**/*.test.ts", "**/*.test.tsx"],
|
|
40
40
|
};
|
|
41
41
|
const IGNORE_TAG = "kumiko-lint-ignore lib-test-coverage";
|
|
42
42
|
|
|
43
43
|
type Export = { readonly name: string; readonly node: Node };
|
|
44
44
|
|
|
45
45
|
function isTestFile(path: string): boolean {
|
|
46
|
-
return /\.test\.tsx?$/.test(path)
|
|
46
|
+
return /\.test\.tsx?$/.test(path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fixtures/helpers under __tests__/ without a .test. suffix are neither tests
|
|
50
|
+
// (they must not satisfy the coverage link) nor lib sources (no test duty).
|
|
51
|
+
function isTestSupportFile(path: string): boolean {
|
|
52
|
+
return /\/__tests__\//.test(path);
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// Exportierte Callables: `export function f` und `export const f = () => …` /
|
|
@@ -126,7 +132,8 @@ export const guard: AstGuard = {
|
|
|
126
132
|
const libFiles: SourceFile[] = [];
|
|
127
133
|
for (const sf of files) {
|
|
128
134
|
if (isTestFile(sf.getFilePath())) testFiles.push(sf);
|
|
129
|
-
else if (!sf.getFilePath().endsWith(".d.ts")
|
|
135
|
+
else if (!sf.getFilePath().endsWith(".d.ts") && !isTestSupportFile(sf.getFilePath()))
|
|
136
|
+
libFiles.push(sf);
|
|
130
137
|
}
|
|
131
138
|
for (const sf of libFiles) {
|
|
132
139
|
const relevant = exportedCallables(sf).filter((e) => !hasIgnoreTag(e.node, IGNORE_TAG));
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
type AstGuard,
|
|
25
|
+
type GuardViolation,
|
|
26
|
+
isLocalFinding,
|
|
27
|
+
runStandalone,
|
|
28
|
+
type ScanSpec,
|
|
29
|
+
} from "./_lib/guard-kit";
|
|
24
30
|
|
|
25
31
|
const ROOT = process.cwd();
|
|
26
32
|
|
|
@@ -118,7 +124,7 @@ export const guard: AstGuard = {
|
|
|
118
124
|
const violations: GuardViolation[] = [];
|
|
119
125
|
for (const sf of files) {
|
|
120
126
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
121
|
-
for (const v of collectViolations(sf)) {
|
|
127
|
+
for (const v of collectViolations(sf).filter(isLocalFinding)) {
|
|
122
128
|
violations.push({
|
|
123
129
|
file: v.file,
|
|
124
130
|
line: v.line,
|
|
@@ -44,7 +44,8 @@ export type ProcessEnvFinding = {
|
|
|
44
44
|
|
|
45
45
|
/** Returns true when the line contains a live process.env reference. */
|
|
46
46
|
export function processEnvOnLine(line: string): boolean {
|
|
47
|
-
|
|
47
|
+
// `(?<!:)` keeps `https://…` inside string literals from being cut as a comment.
|
|
48
|
+
const code = line.replace(/(?<!:)\/\/.*$/, "");
|
|
48
49
|
return PROCESS_ENV_REF.test(code);
|
|
49
50
|
}
|
|
50
51
|
|
|
@@ -236,7 +236,11 @@ function isDirectlyUnderSlotsHeader(obj: ObjectLiteralExpression): boolean {
|
|
|
236
236
|
const slotsObject = headerAssignment.getFirstAncestorByKind(SyntaxKind.ObjectLiteralExpression);
|
|
237
237
|
if (slotsObject === undefined) return false;
|
|
238
238
|
const slotsAssignment = slotsObject.getParentIfKind(SyntaxKind.PropertyAssignment);
|
|
239
|
-
|
|
239
|
+
if (slotsAssignment === undefined || slotsAssignment.getName() !== "slots") return false;
|
|
240
|
+
// Only entityEdit screens render `slots.header` as a bare header slot; other
|
|
241
|
+
// screen types (projectionDetail, entityList) use `slots` differently.
|
|
242
|
+
const screenObject = slotsAssignment.getParentIfKind(SyntaxKind.ObjectLiteralExpression);
|
|
243
|
+
return screenObject !== undefined && stringPropertyValue(screenObject, "type") === "entityEdit";
|
|
240
244
|
}
|
|
241
245
|
|
|
242
246
|
function mountKindFor(componentPropertyAssignment: Node): MountKind {
|
|
@@ -175,14 +175,11 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
|
|
|
175
175
|
const line = call.getStartLineNumber();
|
|
176
176
|
const file = relFile(sf, roots);
|
|
177
177
|
findings.push({ file, line, fieldName, hint });
|
|
178
|
-
console.warn(
|
|
179
|
-
` [pii-annotations WARN] ${file}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
|
|
180
|
-
);
|
|
181
178
|
}
|
|
182
179
|
return findings;
|
|
183
180
|
}
|
|
184
181
|
|
|
185
|
-
function scan(
|
|
182
|
+
export function scan(
|
|
186
183
|
files: readonly SourceFile[],
|
|
187
184
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
188
185
|
): {
|
|
@@ -243,6 +240,10 @@ function analyse(
|
|
|
243
240
|
): GuardOutcome {
|
|
244
241
|
const { findings } = scan(files, roots);
|
|
245
242
|
if (!compareBaseline) {
|
|
243
|
+
for (const f of findings)
|
|
244
|
+
console.warn(
|
|
245
|
+
` [pii-annotations WARN] ${f.file}:${f.line} field "${f.fieldName}" looks like PII — mark ${f.hint}`,
|
|
246
|
+
);
|
|
246
247
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
247
248
|
return { violations: [] };
|
|
248
249
|
}
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* <dialog> → <DefaultDialog>
|
|
23
23
|
* alert() / window.alert() → <DefaultDialog> from @cosmicdrift/kumiko-renderer-web
|
|
24
24
|
* className "bg-card" (hand-rolled card) → <Card> (slots/options)
|
|
25
|
+
* className "mx-auto" + "max-w-*" (hand-rolled screen container)
|
|
26
|
+
* → <PageSection maxWidth> (3xl/4xl/full)
|
|
25
27
|
*
|
|
26
28
|
* Allowed: containers and text — `<div>`, `<span>`, `<section>`,
|
|
27
29
|
* `<header>`, `<main>`, `<nav>`, `<aside>`, `<article>`,
|
|
@@ -73,6 +75,16 @@ const FORBIDDEN_CLASSES: ReadonlyArray<{
|
|
|
73
75
|
readonly counterpart: string;
|
|
74
76
|
}> = [{ token: "bg-card", counterpart: "<Card>" }];
|
|
75
77
|
|
|
78
|
+
// Hand-rolled screen container: `mx-auto` and a `max-w-*` token in the same
|
|
79
|
+
// line. Screens get their width from `<PageSection maxWidth>`; a private
|
|
80
|
+
// container drifts from the shell (an ai-models screen rendered 896px wide).
|
|
81
|
+
const SCREEN_CONTAINER_LABEL = "mx-auto + max-w-*";
|
|
82
|
+
const SCREEN_CONTAINER_COUNTERPART = "<PageSection maxWidth> (3xl/4xl/full)";
|
|
83
|
+
const MX_AUTO_PATTERN = /(?<![\w-])mx-auto(?![\w-])/u;
|
|
84
|
+
// Arbitrary values (`max-w-[1100px]`) contain `[`/`]`/`.`, so the token body
|
|
85
|
+
// is any non-whitespace/non-quote run.
|
|
86
|
+
const MAX_W_PATTERN = /(?<![\w-])max-w-[^\s"'`}]+/u;
|
|
87
|
+
|
|
76
88
|
const IGNORE_TAG = "kumiko-lint-ignore primitives-discipline";
|
|
77
89
|
|
|
78
90
|
export type ScopeKind = "bundled-features" | "samples" | "app";
|
|
@@ -80,6 +92,8 @@ export type ScopeKind = "bundled-features" | "samples" | "app";
|
|
|
80
92
|
type ScanScope = {
|
|
81
93
|
readonly kind: ScopeKind;
|
|
82
94
|
readonly root: string;
|
|
95
|
+
/** Repo root the reported file labels are relative to. */
|
|
96
|
+
readonly repoRoot: string;
|
|
83
97
|
};
|
|
84
98
|
|
|
85
99
|
// Framework checkout enforces bundled-features + samples. App-repo checkout
|
|
@@ -91,29 +105,29 @@ type ScanScope = {
|
|
|
91
105
|
function resolveScopes(roots: readonly RepoRoot[]): ScanScope[] {
|
|
92
106
|
const frameworkRoot = roots.find((r) => r.kind === "framework");
|
|
93
107
|
if (frameworkRoot) {
|
|
108
|
+
const repoRoot = frameworkRoot.absPath;
|
|
94
109
|
return [
|
|
95
110
|
{
|
|
96
111
|
kind: "bundled-features",
|
|
97
|
-
root: path.join(
|
|
112
|
+
root: path.join(repoRoot, "packages/bundled-features/src"),
|
|
113
|
+
repoRoot,
|
|
98
114
|
},
|
|
99
|
-
{ kind: "samples", root: path.join(
|
|
115
|
+
{ kind: "samples", root: path.join(repoRoot, "samples"), repoRoot },
|
|
100
116
|
];
|
|
101
117
|
}
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
return
|
|
118
|
+
const appRoots = roots.filter((r) => r.kind === "app" && isFlatSrcLayout(r));
|
|
119
|
+
if (appRoots.length > 0) {
|
|
120
|
+
return appRoots.map((r) => ({
|
|
121
|
+
kind: "app" as const,
|
|
122
|
+
root: path.join(r.absPath, "src"),
|
|
123
|
+
repoRoot: r.absPath,
|
|
124
|
+
}));
|
|
105
125
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const frameworkRoot = roots.find((r) => r.kind === "framework");
|
|
112
|
-
if (frameworkRoot) return frameworkRoot.absPath;
|
|
113
|
-
const appRoot = roots.find((r) => r.kind === "app" && isFlatSrcLayout(r));
|
|
114
|
-
if (appRoot) return appRoot.absPath;
|
|
115
|
-
const otherRoots = roots.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r));
|
|
116
|
-
return otherRoots[0]?.absPath ?? process.cwd();
|
|
126
|
+
return roots
|
|
127
|
+
.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r))
|
|
128
|
+
.flatMap((r) =>
|
|
129
|
+
sourceRootDirs(r).map((root) => ({ kind: "app" as const, root, repoRoot: r.absPath })),
|
|
130
|
+
);
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
type Violation = {
|
|
@@ -127,13 +141,14 @@ type Violation = {
|
|
|
127
141
|
};
|
|
128
142
|
|
|
129
143
|
function isWebFile(absPath: string, scopeRoot: string): boolean {
|
|
130
|
-
// Only .tsx files under a `web/` or `public/` path segment
|
|
144
|
+
// Only .tsx files under a `web/`, `client/` or `public/` path segment
|
|
145
|
+
// (`client` = enterprise ai-foundation keeps its web code there; prevents
|
|
131
146
|
// scanning random .tsx in screens/ or feature roots — those are schema
|
|
132
147
|
// definitions, not web code).
|
|
133
148
|
if (!absPath.endsWith(".tsx")) return false;
|
|
134
149
|
const rel = path.relative(scopeRoot, absPath);
|
|
135
150
|
const parts = rel.split(path.sep);
|
|
136
|
-
return parts.includes("web") || parts.includes("public");
|
|
151
|
+
return parts.includes("web") || parts.includes("client") || parts.includes("public");
|
|
137
152
|
}
|
|
138
153
|
|
|
139
154
|
function walk(dir: string, out: string[]): void {
|
|
@@ -178,6 +193,12 @@ const CLASS_PATTERNS = FORBIDDEN_CLASSES.map((c) => ({
|
|
|
178
193
|
pattern: new RegExp(`(?<![\\w-])${c.token}(?![\\w-])`, "u"),
|
|
179
194
|
}));
|
|
180
195
|
|
|
196
|
+
function isPublicPage(file: string): boolean {
|
|
197
|
+
// Public pages live outside the app shell; PublicShell is their container,
|
|
198
|
+
// so a centered max-width there is not a screen container.
|
|
199
|
+
return file.split(path.sep).includes("public");
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
function hasIgnore(currentLine: string, prevLine: string): boolean {
|
|
182
203
|
return currentLine.includes(IGNORE_TAG) || prevLine.includes(IGNORE_TAG);
|
|
183
204
|
}
|
|
@@ -190,6 +211,7 @@ export function checkFile(
|
|
|
190
211
|
const text = fs.readFileSync(file, "utf-8");
|
|
191
212
|
const lines = text.split("\n");
|
|
192
213
|
const violations: Violation[] = [];
|
|
214
|
+
const publicPage = isPublicPage(path.relative(root, file));
|
|
193
215
|
let inBlockComment = false;
|
|
194
216
|
for (let i = 0; i < lines.length; i++) {
|
|
195
217
|
const line = lines[i] ?? "";
|
|
@@ -252,6 +274,17 @@ export function checkFile(
|
|
|
252
274
|
});
|
|
253
275
|
}
|
|
254
276
|
}
|
|
277
|
+
if (!publicPage && MX_AUTO_PATTERN.test(codeOnly) && MAX_W_PATTERN.test(codeOnly)) {
|
|
278
|
+
violations.push({
|
|
279
|
+
file: path.relative(root, file),
|
|
280
|
+
line: i + 1,
|
|
281
|
+
kind: "class",
|
|
282
|
+
tag: SCREEN_CONTAINER_LABEL,
|
|
283
|
+
counterpart: SCREEN_CONTAINER_COUNTERPART,
|
|
284
|
+
excerpt: trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed,
|
|
285
|
+
scope,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
255
288
|
}
|
|
256
289
|
return violations;
|
|
257
290
|
}
|
|
@@ -275,7 +308,6 @@ export const check: RepoCheck = {
|
|
|
275
308
|
// Repo-Root aufgelöst") — the vacuity floor below reproduces that.
|
|
276
309
|
return { violations: [], matchedFiles: 0, notApplicable: false };
|
|
277
310
|
}
|
|
278
|
-
const root = resolveRoot(roots);
|
|
279
311
|
// Pre-filter count (every file walk() found, before the web/-segment
|
|
280
312
|
// filter): an app repo whose screens are schema-driven with zero
|
|
281
313
|
// web/*.tsx (phronexsis before its first screen) must not be flagged as
|
|
@@ -291,7 +323,7 @@ export const check: RepoCheck = {
|
|
|
291
323
|
const webFiles = files.filter((f) => isWebFile(f, scope.root));
|
|
292
324
|
for (const file of webFiles) {
|
|
293
325
|
const target = scope.kind === "samples" ? warnings : violations;
|
|
294
|
-
for (const v of checkFile(file, scope.kind,
|
|
326
|
+
for (const v of checkFile(file, scope.kind, scope.repoRoot)) {
|
|
295
327
|
target.push({ file: v.file, line: v.line, message: violationMessage(v) });
|
|
296
328
|
}
|
|
297
329
|
}
|
|
@@ -101,17 +101,22 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const BASELINE_FILE = ".kumiko-raw-interactive-elements-baseline.json";
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
const createBaseline = (baselineRoot: string) =>
|
|
105
|
+
baselineRatchet({
|
|
106
|
+
file: path.join(baselineRoot, BASELINE_FILE),
|
|
107
|
+
formatVersion: 1,
|
|
108
|
+
unit: "raw interactive HTML finding(s)",
|
|
109
|
+
});
|
|
109
110
|
|
|
110
111
|
const REMEDIATION =
|
|
111
112
|
"Framework replacement: <a> → usePrimitives().Link, <details>/<summary> → CollapsibleSection " +
|
|
112
113
|
`(@cosmicdrift/kumiko-renderer-web). Genuine exception: // ${IGNORE_TAG} <reason>`;
|
|
113
114
|
|
|
114
|
-
function analyse(
|
|
115
|
+
function analyse(
|
|
116
|
+
files: readonly SourceFile[],
|
|
117
|
+
compareBaseline: boolean,
|
|
118
|
+
baselineRoot: string,
|
|
119
|
+
): GuardOutcome {
|
|
115
120
|
const findings = scan(files);
|
|
116
121
|
if (!compareBaseline) {
|
|
117
122
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
@@ -119,7 +124,7 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
119
124
|
}
|
|
120
125
|
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
121
126
|
return {
|
|
122
|
-
violations:
|
|
127
|
+
violations: createBaseline(baselineRoot).check(baselineCounts(findings), REMEDIATION, {
|
|
123
128
|
formatDriftRemediation:
|
|
124
129
|
"Run `bun guards/guard-raw-interactive-elements.ts --write-baseline` once.",
|
|
125
130
|
resolveLine,
|
|
@@ -127,12 +132,17 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
127
132
|
};
|
|
128
133
|
}
|
|
129
134
|
|
|
130
|
-
export
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
export function createGuard(options: { readonly baselineRoot?: string } = {}): AstGuard {
|
|
136
|
+
const baselineRoot = options.baselineRoot ?? ROOT;
|
|
137
|
+
return {
|
|
138
|
+
name: "Raw-Interactive-Elements Guard (App-Repos)",
|
|
139
|
+
scan: SCAN,
|
|
140
|
+
hint: REMEDIATION,
|
|
141
|
+
run: (files: readonly SourceFile[]) => analyse(files, true, baselineRoot),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const guard: AstGuard = createGuard();
|
|
136
146
|
|
|
137
147
|
// Flags are read ONLY here, not in run() — the shared runner
|
|
138
148
|
// (run-ui-guards.ts) runs every guard with the same argv, a
|
|
@@ -142,12 +152,12 @@ if (import.meta.main) {
|
|
|
142
152
|
if (args.includes("--write-baseline")) {
|
|
143
153
|
const project = buildSharedProject([guard]);
|
|
144
154
|
const findings = scan(filesForGuard(project, guard));
|
|
145
|
-
|
|
155
|
+
createBaseline(ROOT).write(baselineCounts(findings));
|
|
146
156
|
process.exit(0);
|
|
147
157
|
}
|
|
148
158
|
if (args.includes("--no-baseline")) {
|
|
149
159
|
const project = buildSharedProject([guard]);
|
|
150
|
-
analyse(filesForGuard(project, guard), false);
|
|
160
|
+
analyse(filesForGuard(project, guard), false, ROOT);
|
|
151
161
|
process.exit(0);
|
|
152
162
|
}
|
|
153
163
|
runStandalone(guard);
|
|
@@ -163,11 +163,12 @@ function countByFile(findings: readonly Finding[]): Record<string, number> {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
const BASELINE_FILE = ".kumiko-tailwind-scan-surface-baseline.json";
|
|
166
|
-
const tailwindScanSurfaceBaseline =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
const tailwindScanSurfaceBaseline = (baselineRoot: string) =>
|
|
167
|
+
baselineRatchet({
|
|
168
|
+
file: path.join(baselineRoot, BASELINE_FILE),
|
|
169
|
+
formatVersion: 1,
|
|
170
|
+
unit: "class token(s)",
|
|
171
|
+
});
|
|
171
172
|
|
|
172
173
|
// Second, independent rule: a consuming app repo (studio,
|
|
173
174
|
// publicstatus, …) imports Framework AND Enterprise packages by npm name
|
|
@@ -489,11 +490,12 @@ function reportPublishedScanSurfaceFindings(
|
|
|
489
490
|
}
|
|
490
491
|
|
|
491
492
|
const SOURCE_COVERAGE_BASELINE_FILE = ".kumiko-tailwind-source-coverage-baseline.json";
|
|
492
|
-
const sourceCoverageBaseline =
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
493
|
+
const sourceCoverageBaseline = (baselineRoot: string) =>
|
|
494
|
+
baselineRatchet({
|
|
495
|
+
file: path.join(baselineRoot, SOURCE_COVERAGE_BASELINE_FILE),
|
|
496
|
+
formatVersion: 1,
|
|
497
|
+
unit: "missing @source entry/entries",
|
|
498
|
+
});
|
|
497
499
|
|
|
498
500
|
export function sourceCoverageBaselineCounts(
|
|
499
501
|
findings: readonly SourceCoverageFinding[],
|
|
@@ -503,9 +505,12 @@ export function sourceCoverageBaselineCounts(
|
|
|
503
505
|
return counts;
|
|
504
506
|
}
|
|
505
507
|
|
|
506
|
-
function checkSourceCoverageBaseline(
|
|
508
|
+
function checkSourceCoverageBaseline(
|
|
509
|
+
findings: readonly SourceCoverageFinding[],
|
|
510
|
+
baselineRoot: string,
|
|
511
|
+
): GuardViolation[] {
|
|
507
512
|
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
508
|
-
return sourceCoverageBaseline.check(
|
|
513
|
+
return sourceCoverageBaseline(baselineRoot).check(
|
|
509
514
|
sourceCoverageBaselineCounts(findings),
|
|
510
515
|
"Package delivers Tailwind classes without @source coverage in this app — add an @source entry for both install layouts (pattern: existing bundled-features entries).",
|
|
511
516
|
{
|
|
@@ -522,9 +527,9 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
|
|
|
522
527
|
return countByFile(findings);
|
|
523
528
|
}
|
|
524
529
|
|
|
525
|
-
function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
|
|
530
|
+
function checkBaseline(findings: readonly Finding[], baselineRoot: string): GuardViolation[] {
|
|
526
531
|
const resolveLine = (file: string): number => findings.find((f) => f.file === file)?.line ?? 1;
|
|
527
|
-
return tailwindScanSurfaceBaseline.check(
|
|
532
|
+
return tailwindScanSurfaceBaseline(baselineRoot).check(
|
|
528
533
|
baselineCounts(findings),
|
|
529
534
|
"Class outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) — reuse a class already emitted there, or move the styling into renderer-web.",
|
|
530
535
|
{
|
|
@@ -539,6 +544,7 @@ export function analyse(
|
|
|
539
544
|
files: readonly SourceFile[],
|
|
540
545
|
compareBaseline: boolean,
|
|
541
546
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
547
|
+
baselineRoot: string = BASELINE_ROOT,
|
|
542
548
|
): GuardOutcome {
|
|
543
549
|
const { findings } = scan(files, roots);
|
|
544
550
|
const coverageFindings = scanSourceCoverage(roots);
|
|
@@ -550,23 +556,28 @@ export function analyse(
|
|
|
550
556
|
}
|
|
551
557
|
return {
|
|
552
558
|
violations: [
|
|
553
|
-
...checkBaseline(findings),
|
|
554
|
-
...checkSourceCoverageBaseline(coverageFindings),
|
|
559
|
+
...checkBaseline(findings, baselineRoot),
|
|
560
|
+
...checkSourceCoverageBaseline(coverageFindings, baselineRoot),
|
|
555
561
|
...publishedScanSurfaceViolations(publishedSurfaceFindings),
|
|
556
562
|
],
|
|
557
563
|
};
|
|
558
564
|
}
|
|
559
565
|
|
|
560
|
-
export
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
566
|
+
export function createGuard(options: { readonly baselineRoot?: string } = {}): AstGuard {
|
|
567
|
+
const baselineRoot = options.baselineRoot ?? BASELINE_ROOT;
|
|
568
|
+
return {
|
|
569
|
+
name: "Tailwind-Scan-Surface Guard",
|
|
570
|
+
scan: SCAN,
|
|
571
|
+
hint:
|
|
572
|
+
"Tailwind class in bundled-features outside the @source scan surface (renderer-web/src, renderer/src, samples/**/src) " +
|
|
573
|
+
`— reuse a class already emitted there, or move the styling into renderer-web. Justified exception: // ${IGNORE_TAG} <reason>. ` +
|
|
574
|
+
"Package without @source coverage in an app: add an @source entry for both install layouts. " +
|
|
575
|
+
"@source with a node_modules/<pkg>/<segment> path: check whether <pkg> even publishes that segment (package.json files).",
|
|
576
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, true, roots, baselineRoot),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export const guard: AstGuard = createGuard();
|
|
570
581
|
|
|
571
582
|
// Flags are read ONLY here, not in run() — the shared runner
|
|
572
583
|
// (run-ui-guards.ts) runs every guard with the same argv, a
|
|
@@ -576,8 +587,8 @@ if (import.meta.main) {
|
|
|
576
587
|
if (args.includes("--write-baseline")) {
|
|
577
588
|
const project = buildSharedProject([guard]);
|
|
578
589
|
const { findings } = scan(filesForGuard(project, guard), resolveRepoRoots());
|
|
579
|
-
tailwindScanSurfaceBaseline.write(baselineCounts(findings));
|
|
580
|
-
sourceCoverageBaseline.write(
|
|
590
|
+
tailwindScanSurfaceBaseline(BASELINE_ROOT).write(baselineCounts(findings));
|
|
591
|
+
sourceCoverageBaseline(BASELINE_ROOT).write(
|
|
581
592
|
sourceCoverageBaselineCounts(scanSourceCoverage(resolveRepoRoots())),
|
|
582
593
|
);
|
|
583
594
|
process.exit(0);
|
|
@@ -47,17 +47,16 @@ import * as path from "node:path";
|
|
|
47
47
|
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
48
48
|
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
49
49
|
import { literalStringOf, mentionsAsWord, nameForms } from "./_lib/handler-name-forms";
|
|
50
|
+
import { TEST_FILE_RE } from "./_lib/test-file";
|
|
50
51
|
|
|
51
52
|
const ROOT = process.cwd();
|
|
52
53
|
|
|
53
54
|
const SCAN: ScanSpec = {
|
|
54
|
-
scope: "source",
|
|
55
|
+
scope: "source+tests",
|
|
55
56
|
extensions: ["ts"],
|
|
56
57
|
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
57
58
|
};
|
|
58
59
|
|
|
59
|
-
const TEST_FILE = /\.test\.ts$/;
|
|
60
|
-
|
|
61
60
|
// Quoted forms — a test "rejects this role" assertion carries the literal.
|
|
62
61
|
const RESERVED_ROLE_LITERALS = ['"SystemAdmin"', '"system"', '"all"', '"anonymous"'];
|
|
63
62
|
|
|
@@ -81,7 +80,7 @@ function resolvedSchemaText(schemaProp: Node): string {
|
|
|
81
80
|
export function findRoleInputHandlers(files: readonly SourceFile[]): RoleHandler[] {
|
|
82
81
|
const out: RoleHandler[] = [];
|
|
83
82
|
for (const sf of files) {
|
|
84
|
-
if (
|
|
83
|
+
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
85
84
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
86
85
|
if (call.getExpression().getText() !== "defineWriteHandler") continue;
|
|
87
86
|
const arg = call.getArguments()[0];
|
|
@@ -105,7 +104,7 @@ export function findRoleInputHandlers(files: readonly SourceFile[]): RoleHandler
|
|
|
105
104
|
function reservedRoleTestTexts(files: readonly SourceFile[]): string[] {
|
|
106
105
|
const out: string[] = [];
|
|
107
106
|
for (const sf of files) {
|
|
108
|
-
if (!
|
|
107
|
+
if (!TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
109
108
|
const text = sf.getFullText();
|
|
110
109
|
if (RESERVED_ROLE_LITERALS.some((r) => text.includes(r))) out.push(text);
|
|
111
110
|
}
|
|
@@ -133,7 +132,7 @@ type OverrideHandler = { file: string; line: number };
|
|
|
133
132
|
export function findOverrideHandlersMissingHelper(files: readonly SourceFile[]): OverrideHandler[] {
|
|
134
133
|
const out: OverrideHandler[] = [];
|
|
135
134
|
for (const sf of files) {
|
|
136
|
-
if (
|
|
135
|
+
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
137
136
|
for (const pa of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
138
137
|
if (pa.getName() !== "tenantIdOverride") continue;
|
|
139
138
|
if (pa.getInitializer()?.getText().startsWith("z.") !== true) continue;
|
|
@@ -181,7 +180,7 @@ function lineOfMatch(text: string, re: RegExp): number {
|
|
|
181
180
|
export function findMembershipMintsMissingStrip(files: readonly SourceFile[]): MintSite[] {
|
|
182
181
|
const out: MintSite[] = [];
|
|
183
182
|
for (const sf of files) {
|
|
184
|
-
if (
|
|
183
|
+
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
185
184
|
const text = sf.getFullText();
|
|
186
185
|
if (!SESSION_MINT.test(text)) continue;
|
|
187
186
|
if (!MEMBERSHIP_SOURCE.test(text)) continue;
|
|
@@ -223,7 +222,7 @@ export function findGlobalUserWritesMissingMembershipCheck(
|
|
|
223
222
|
): GlobalUserWrite[] {
|
|
224
223
|
const out: GlobalUserWrite[] = [];
|
|
225
224
|
for (const sf of files) {
|
|
226
|
-
if (
|
|
225
|
+
if (TEST_FILE_RE.test(sf.getFilePath())) continue;
|
|
227
226
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
228
227
|
if (call.getExpression().getText() !== "defineWriteHandler") continue;
|
|
229
228
|
const arg = call.getArguments()[0];
|