@cosmicdrift/kumiko-guards 0.331.0 → 0.333.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 +44 -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 +19 -18
- 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
|
}
|
|
@@ -92,6 +92,8 @@ export type ScopeKind = "bundled-features" | "samples" | "app";
|
|
|
92
92
|
type ScanScope = {
|
|
93
93
|
readonly kind: ScopeKind;
|
|
94
94
|
readonly root: string;
|
|
95
|
+
/** Repo root the reported file labels are relative to. */
|
|
96
|
+
readonly repoRoot: string;
|
|
95
97
|
};
|
|
96
98
|
|
|
97
99
|
// Framework checkout enforces bundled-features + samples. App-repo checkout
|
|
@@ -103,29 +105,29 @@ type ScanScope = {
|
|
|
103
105
|
function resolveScopes(roots: readonly RepoRoot[]): ScanScope[] {
|
|
104
106
|
const frameworkRoot = roots.find((r) => r.kind === "framework");
|
|
105
107
|
if (frameworkRoot) {
|
|
108
|
+
const repoRoot = frameworkRoot.absPath;
|
|
106
109
|
return [
|
|
107
110
|
{
|
|
108
111
|
kind: "bundled-features",
|
|
109
|
-
root: path.join(
|
|
112
|
+
root: path.join(repoRoot, "packages/bundled-features/src"),
|
|
113
|
+
repoRoot,
|
|
110
114
|
},
|
|
111
|
-
{ kind: "samples", root: path.join(
|
|
115
|
+
{ kind: "samples", root: path.join(repoRoot, "samples"), repoRoot },
|
|
112
116
|
];
|
|
113
117
|
}
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
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
|
+
}));
|
|
117
125
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const frameworkRoot = roots.find((r) => r.kind === "framework");
|
|
124
|
-
if (frameworkRoot) return frameworkRoot.absPath;
|
|
125
|
-
const appRoot = roots.find((r) => r.kind === "app" && isFlatSrcLayout(r));
|
|
126
|
-
if (appRoot) return appRoot.absPath;
|
|
127
|
-
const otherRoots = roots.filter((r) => r.kind !== "framework" && !isFlatSrcLayout(r));
|
|
128
|
-
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
|
+
);
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
type Violation = {
|
|
@@ -306,7 +308,6 @@ export const check: RepoCheck = {
|
|
|
306
308
|
// Repo-Root aufgelöst") — the vacuity floor below reproduces that.
|
|
307
309
|
return { violations: [], matchedFiles: 0, notApplicable: false };
|
|
308
310
|
}
|
|
309
|
-
const root = resolveRoot(roots);
|
|
310
311
|
// Pre-filter count (every file walk() found, before the web/-segment
|
|
311
312
|
// filter): an app repo whose screens are schema-driven with zero
|
|
312
313
|
// web/*.tsx (phronexsis before its first screen) must not be flagged as
|
|
@@ -322,7 +323,7 @@ export const check: RepoCheck = {
|
|
|
322
323
|
const webFiles = files.filter((f) => isWebFile(f, scope.root));
|
|
323
324
|
for (const file of webFiles) {
|
|
324
325
|
const target = scope.kind === "samples" ? warnings : violations;
|
|
325
|
-
for (const v of checkFile(file, scope.kind,
|
|
326
|
+
for (const v of checkFile(file, scope.kind, scope.repoRoot)) {
|
|
326
327
|
target.push({ file: v.file, line: v.line, message: violationMessage(v) });
|
|
327
328
|
}
|
|
328
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];
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { Project, type SourceFile, SyntaxKind } from "ts-morph";
|
|
20
|
+
import { type Identifier, Node, Project, type SourceFile, SyntaxKind } from "ts-morph";
|
|
21
21
|
import {
|
|
22
22
|
type GuardViolation,
|
|
23
23
|
type RepoCheck,
|
|
@@ -70,10 +70,23 @@ export interface Violation {
|
|
|
70
70
|
forbiddenCalls: Array<{ name: string; line: number }>;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// `import { setupTestStack as stack }` + `stack()` must resolve to the imported
|
|
74
|
+
// name, otherwise an alias hides both entrypoints and forbidden factories.
|
|
75
|
+
function importedNameOf(identifier: Identifier): string {
|
|
76
|
+
const localName = identifier.getText();
|
|
77
|
+
for (const imp of identifier.getSourceFile().getImportDeclarations()) {
|
|
78
|
+
for (const spec of imp.getNamedImports()) {
|
|
79
|
+
if ((spec.getAliasNode()?.getText() ?? spec.getName()) === localName) return spec.getName();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return localName;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
function collectCallNames(sf: SourceFile): Map<string, number[]> {
|
|
74
86
|
const calls = new Map<string, number[]>();
|
|
75
87
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
76
|
-
const
|
|
88
|
+
const callee = call.getExpression();
|
|
89
|
+
const name = Node.isIdentifier(callee) ? importedNameOf(callee) : callee.getText();
|
|
77
90
|
const arr = calls.get(name) ?? [];
|
|
78
91
|
arr.push(call.getStartLineNumber());
|
|
79
92
|
calls.set(name, arr);
|
|
@@ -144,9 +144,6 @@ function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding
|
|
|
144
144
|
const place = enclosingFieldName(call) ?? `${callee}(...)`;
|
|
145
145
|
const line = call.getStartLineNumber();
|
|
146
146
|
findings.push({ file, line, place, callee });
|
|
147
|
-
console.warn(
|
|
148
|
-
` [text-field-stance WARN] ${file}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
|
|
149
|
-
);
|
|
150
147
|
}
|
|
151
148
|
return findings;
|
|
152
149
|
}
|
|
@@ -212,6 +209,10 @@ function analyse(
|
|
|
212
209
|
): GuardOutcome {
|
|
213
210
|
const { findings } = scan(files, roots);
|
|
214
211
|
if (!compareBaseline) {
|
|
212
|
+
for (const f of findings)
|
|
213
|
+
console.warn(
|
|
214
|
+
` [text-field-stance WARN] ${f.file}:${f.line} ${f.callee}(...) at "${f.place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
|
|
215
|
+
);
|
|
215
216
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
216
217
|
return { violations: [] };
|
|
217
218
|
}
|
|
@@ -67,6 +67,8 @@ function isKnownReason(r: string): r is WrapperReason {
|
|
|
67
67
|
// ── Built-in filter ─────────────────────────────────────────────────────
|
|
68
68
|
|
|
69
69
|
const BUILTIN_NAMES = new Set([
|
|
70
|
+
"test",
|
|
71
|
+
"exec",
|
|
70
72
|
"push",
|
|
71
73
|
"pop",
|
|
72
74
|
"shift",
|
|
@@ -300,9 +302,6 @@ function extractCalleeName(callNode: ReturnType<FnNode["getBody"]> | undefined):
|
|
|
300
302
|
if (!expr) return null;
|
|
301
303
|
if (expr.isKind(SyntaxKind.Identifier)) return expr.getText();
|
|
302
304
|
if (expr.isKind(SyntaxKind.PropertyAccessExpression)) {
|
|
303
|
-
// `/re/.test(x)` is a RegExp method call, not a call to a function named
|
|
304
|
-
// "test" — a regex-literal receiver is never a wrapper callee.
|
|
305
|
-
if (expr.getExpression().isKind(SyntaxKind.RegularExpressionLiteral)) return null;
|
|
306
305
|
return expr.getName();
|
|
307
306
|
}
|
|
308
307
|
return null;
|