@cosmicdrift/kumiko-guards 0.335.0 → 0.336.1
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/guard-kit.ts +4 -0
- package/src/_lib/scan-scope.ts +1 -1
- package/src/changes.json +13 -0
- package/src/check-as-casts.ts +7 -0
- package/src/check-runtime-isolation.ts +2 -2
- package/src/check-secret-literals.ts +18 -13
- package/src/guard-escape-hatch-declared.ts +13 -2
- package/src/guard-fake-tests.ts +32 -25
- package/src/guard-i18n-locale-terminology.ts +1 -1
- package/src/guard-no-custom-primitives.ts +3 -2
- package/src/guard-no-framed-extension-sections.ts +38 -7
- package/src/guard-no-inline-styles.ts +9 -3
- package/src/guard-no-logic-in-views.ts +8 -2
- package/src/guard-no-raw-hooks.ts +11 -4
- package/src/guard-primitives-discipline.ts +3 -2
- package/src/guard-raw-classname.ts +8 -2
- package/src/guard-test-timeouts.ts +21 -2
- package/src/guard-thin-wrappers.ts +2 -2
- package/src/pre-push.sh +2 -2
- package/src/run-guards.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.336.1",
|
|
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.336.1",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -125,6 +125,10 @@ export function relFromRepoRoot(
|
|
|
125
125
|
return filePath.slice(filePath.indexOf(marker));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
export function relativeToCwd(absolutePath: string): string {
|
|
129
|
+
return pathRelative(process.cwd(), absolutePath);
|
|
130
|
+
}
|
|
131
|
+
|
|
128
132
|
export function isAllowlisted(relPath: string, allowlist: readonly RegExp[]): boolean {
|
|
129
133
|
return allowlist.some((re) => re.test(relPath));
|
|
130
134
|
}
|
package/src/_lib/scan-scope.ts
CHANGED
|
@@ -164,7 +164,7 @@ function scanRoot(spec: ScanSpec, root: RepoRoot): RootScan {
|
|
|
164
164
|
return { root, files, sourceSurface: sourceSurfaceHits.length };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
|
|
167
|
+
export function keepsRootKind(spec: ScanSpec, root: RepoRoot): boolean {
|
|
168
168
|
if (spec.kinds) return spec.kinds.includes(root.kind);
|
|
169
169
|
return root.kind !== "tooling";
|
|
170
170
|
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.336.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Guards reject --guard without --write-baseline and catch more raw-form-HTML, locale and timeout cases",
|
|
6
|
+
"detail": "Guards are stricter and more consistent: `guards --guard=<name>` without `--write-baseline` is rejected instead of silently running every guard, the raw-form-HTML check also catches tags after `=`, `[` or in spaceless ternaries, the German locale check flags lowercase \"organisation\", `test-timeouts` flags `setDefaultTimeout` and `describe.configure({ timeout })`, the no-framed-extension-sections guard correlates registry keys and usages by resolved value, and the fake-tests helper lookup is scope-aware. UI guards report repo-relative file paths. The pre-push worktree detection compares absolute git dirs."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.336.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "secret-literal checks all fallbacks on a line; as-casts audit is quiet inside the shared runner",
|
|
12
|
+
"detail": "`secret-literal` now inspects every `?? \"literal\"` fallback on a line instead of only the first, so a hardcoded secret behind a trivial first fallback is flagged. The `as-casts` audit prints its per-site listings only when run standalone; inside the shared runner it prints just the counts and the baseline verdict.",
|
|
13
|
+
"migration": "A repo with a hardcoded secret fallback hidden behind an earlier trivial fallback on the same line can now fail the secret-literal guard. Replace the literal with a hard failure on a missing env value."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.335.0",
|
|
4
17
|
"type": "improvement",
|
package/src/check-as-casts.ts
CHANGED
|
@@ -405,6 +405,11 @@ function groupByCategory(all: readonly Site[]): Map<Category, Site[]> {
|
|
|
405
405
|
return byCat;
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
+
// The per-site listings are advisory and long; inside the shared runner (every consumer's
|
|
409
|
+
// `bun run check`) they would bury the output of guards that can fail, so only a standalone
|
|
410
|
+
// run of this file prints them.
|
|
411
|
+
const PRINT_SITE_LISTINGS = import.meta.main;
|
|
412
|
+
|
|
408
413
|
function reportCasts(all: readonly Site[], scanned: number): void {
|
|
409
414
|
const byCat = groupByCategory(all);
|
|
410
415
|
console.log(`as-Cast Audit: ${scanned} files checked, ${all.length} casts total.\n`);
|
|
@@ -412,6 +417,8 @@ function reportCasts(all: readonly Site[], scanned: number): void {
|
|
|
412
417
|
const count = byCat.get(c)?.length ?? 0;
|
|
413
418
|
console.log(` ${c.padEnd(18)} ${count}`);
|
|
414
419
|
}
|
|
420
|
+
// skip: site listings are printed only on a standalone run of this guard
|
|
421
|
+
if (!PRINT_SITE_LISTINGS) return;
|
|
415
422
|
|
|
416
423
|
const suspects = CATS.filter((c) => c.startsWith("suspect-"));
|
|
417
424
|
|
|
@@ -64,7 +64,7 @@ import {
|
|
|
64
64
|
runRepoChecks,
|
|
65
65
|
} from "./_lib/guard-kit";
|
|
66
66
|
import { frameworkTsConfigPath, type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
67
|
-
import { type ScanSpec, scanFiles
|
|
67
|
+
import { keepsRootKind, type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
68
68
|
import {
|
|
69
69
|
classify,
|
|
70
70
|
computeClientReachablePaths,
|
|
@@ -177,7 +177,7 @@ export const check: RepoCheck = {
|
|
|
177
177
|
// The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
|
|
178
178
|
// default — that's a deliberate scope choice, not a vacuous scan, so it must
|
|
179
179
|
// report notApplicable instead of matchedFiles: 0.
|
|
180
|
-
if (
|
|
180
|
+
if (!keepsRootKind(SCAN, root)) {
|
|
181
181
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
182
182
|
}
|
|
183
183
|
const rootAbsPath = root.absPath;
|
|
@@ -42,7 +42,7 @@ const IS_TEST = /\.(?:test|integration)\.tsx?$/;
|
|
|
42
42
|
const DEV_ENTRYPOINT = /(?:^|\/)bin\/server\.ts$/;
|
|
43
43
|
|
|
44
44
|
// A `?? "literal"` / `|| "literal"` nullish/or fallback to a string literal.
|
|
45
|
-
const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1
|
|
45
|
+
const STRING_FALLBACK = /(?:\?\?|\|\|)\s*(['"`])([^'"`\n]+)\1/g;
|
|
46
46
|
// A secret-like identifier. Matched only against the text LEFT of the fallback
|
|
47
47
|
// (assignee / property name): `cfg.title ?? "Secret Santa"` is a label, not a secret.
|
|
48
48
|
const SECRET_NAME =
|
|
@@ -116,19 +116,24 @@ export function scanLinesForSecretLiterals(lines: readonly string[]): SecretLite
|
|
|
116
116
|
for (let i = 0; i < lines.length; i++) {
|
|
117
117
|
const raw = lines[i] ?? "";
|
|
118
118
|
const code = maskedLines[i] ?? "";
|
|
119
|
-
const match = STRING_FALLBACK.exec(code);
|
|
120
|
-
if (!match) continue;
|
|
121
|
-
const literal = match[2];
|
|
122
|
-
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
123
|
-
const left = code.slice(0, match.index);
|
|
124
|
-
const name =
|
|
125
|
-
SECRET_NAME.exec(left)?.[0] ??
|
|
126
|
-
(SECRET_LITERAL_TOKEN.test(literal)
|
|
127
|
-
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
128
|
-
: undefined);
|
|
129
|
-
if (name === undefined) continue;
|
|
130
119
|
if (raw.includes(IGNORE_TAG) || (lines[i - 1] ?? "").includes(IGNORE_TAG)) continue;
|
|
131
|
-
|
|
120
|
+
// Every fallback on the line is inspected; the name is read only from the text since the
|
|
121
|
+
// previous fallback, so an earlier assignee cannot lend its name to a later literal.
|
|
122
|
+
let previousEnd = 0;
|
|
123
|
+
for (const match of code.matchAll(STRING_FALLBACK)) {
|
|
124
|
+
const left = code.slice(previousEnd, match.index);
|
|
125
|
+
previousEnd = match.index + match[0].length;
|
|
126
|
+
const literal = match[2];
|
|
127
|
+
if (!literal || literal.length < MIN_SECRET_LENGTH || TRIVIAL_LITERAL.test(literal)) continue;
|
|
128
|
+
const name =
|
|
129
|
+
SECRET_NAME.exec(left)?.[0] ??
|
|
130
|
+
(SECRET_LITERAL_TOKEN.test(literal)
|
|
131
|
+
? (LHS_NAME.exec(left)?.[1] ?? UNNAMED_PLACEHOLDER)
|
|
132
|
+
: undefined);
|
|
133
|
+
if (name === undefined) continue;
|
|
134
|
+
hits.push({ lineNumber: i + 1, name, literalLength: literal.length });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
132
137
|
}
|
|
133
138
|
return hits;
|
|
134
139
|
}
|
|
@@ -78,6 +78,7 @@ import {
|
|
|
78
78
|
type PropertyAssignment,
|
|
79
79
|
type SourceFile,
|
|
80
80
|
SyntaxKind,
|
|
81
|
+
VariableDeclarationKind,
|
|
81
82
|
} from "ts-morph";
|
|
82
83
|
import { isFrameworkImportOf } from "./_lib/framework-import";
|
|
83
84
|
import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
|
|
@@ -421,12 +422,22 @@ function isExplicitlyUnresolvableReason(node: Node): boolean {
|
|
|
421
422
|
(decl) =>
|
|
422
423
|
decl.isKind(SyntaxKind.ImportSpecifier) ||
|
|
423
424
|
decl.isKind(SyntaxKind.ImportClause) ||
|
|
424
|
-
decl.isKind(SyntaxKind.NamespaceImport)
|
|
425
|
+
decl.isKind(SyntaxKind.NamespaceImport) ||
|
|
426
|
+
isNonConstVariableDeclaration(decl),
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function isNonConstVariableDeclaration(decl: Node): boolean {
|
|
431
|
+
if (!decl.isKind(SyntaxKind.VariableDeclaration)) return false;
|
|
432
|
+
const list = decl.getParent();
|
|
433
|
+
return (
|
|
434
|
+
list.isKind(SyntaxKind.VariableDeclarationList) &&
|
|
435
|
+
list.getDeclarationKind() !== VariableDeclarationKind.Const
|
|
425
436
|
);
|
|
426
437
|
}
|
|
427
438
|
|
|
428
439
|
const UNRESOLVABLE_REASON_HINT =
|
|
429
|
-
" A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
|
|
440
|
+
" A declareEscapeHatch({ reason }) call was found here, but its reason is an import, a function call, or a template with substitutions (or a `let`/non-`const` binding) — none of those can be statically judged, so declareEscapeHatch needs a string literal or a module-local const instead.";
|
|
430
441
|
|
|
431
442
|
// Walks the same ancestor chain as isInsideEscapeHatchDeclaredFunction, but
|
|
432
443
|
// looks for a declareEscapeHatch statement whose reason is one of the three
|
package/src/guard-fake-tests.ts
CHANGED
|
@@ -77,40 +77,47 @@ function isDirectAssertionCall(call: CallExpression): boolean {
|
|
|
77
77
|
return ASSERTION_API_METHODS.has(name);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// A bare identifier `foo(...)` counts as an assertion when `foo`
|
|
81
|
-
//
|
|
82
|
-
//
|
|
80
|
+
// A bare identifier `foo(...)` counts as an assertion when `foo` resolves
|
|
81
|
+
// (by symbol, so a same-named helper in another scope is not confused with it)
|
|
82
|
+
// to a declaration in the SAME file (function declaration, or a variable
|
|
83
|
+
// initialized with an arrow/function expression) whose own body asserts —
|
|
83
84
|
// recursively, so a chain of thin wrappers around expect() still counts.
|
|
84
|
-
function resolveSameFileHelper(sf: SourceFile,
|
|
85
|
-
const
|
|
86
|
-
.
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
) {
|
|
97
|
-
return init;
|
|
85
|
+
function resolveSameFileHelper(sf: SourceFile, identifier: Node): Node | undefined {
|
|
86
|
+
for (const decl of identifier.getSymbol()?.getDeclarations() ?? []) {
|
|
87
|
+
if (decl.getSourceFile() !== sf) continue;
|
|
88
|
+
if (decl.isKind(SyntaxKind.FunctionDeclaration)) return decl;
|
|
89
|
+
if (!decl.isKind(SyntaxKind.VariableDeclaration)) continue;
|
|
90
|
+
const init = decl.getInitializer();
|
|
91
|
+
if (
|
|
92
|
+
init &&
|
|
93
|
+
(init.isKind(SyntaxKind.ArrowFunction) || init.isKind(SyntaxKind.FunctionExpression))
|
|
94
|
+
) {
|
|
95
|
+
return init;
|
|
96
|
+
}
|
|
98
97
|
}
|
|
99
98
|
return undefined;
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
// depth limit +
|
|
103
|
-
// as unproven (violation),
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
// depth limit + shallowest-visit memo: a helper chain deeper than the limit is
|
|
102
|
+
// treated as unproven (violation), a recursive helper never infinite-loops, and
|
|
103
|
+
// a helper first reached at the depth limit is still explored when reached
|
|
104
|
+
// again via a shorter path.
|
|
105
|
+
function bodyHasAssertion(
|
|
106
|
+
node: Node,
|
|
107
|
+
sf: SourceFile,
|
|
108
|
+
depth: number,
|
|
109
|
+
shallowestVisit: Map<Node, number>,
|
|
110
|
+
): boolean {
|
|
111
|
+
const seenAt = shallowestVisit.get(node);
|
|
112
|
+
if (seenAt !== undefined && seenAt <= depth) return false;
|
|
113
|
+
shallowestVisit.set(node, depth);
|
|
107
114
|
for (const call of node.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
108
115
|
if (isDirectAssertionCall(call)) return true;
|
|
109
116
|
if (depth >= MAX_HELPER_DEPTH) continue;
|
|
110
117
|
const expr = call.getExpression();
|
|
111
118
|
if (!expr.isKind(SyntaxKind.Identifier)) continue;
|
|
112
|
-
const helper = resolveSameFileHelper(sf, expr
|
|
113
|
-
if (helper && bodyHasAssertion(helper, sf, depth + 1,
|
|
119
|
+
const helper = resolveSameFileHelper(sf, expr);
|
|
120
|
+
if (helper && bodyHasAssertion(helper, sf, depth + 1, shallowestVisit)) return true;
|
|
114
121
|
}
|
|
115
122
|
return false;
|
|
116
123
|
}
|
|
@@ -141,7 +148,7 @@ function scanFile(sf: SourceFile): Violation[] {
|
|
|
141
148
|
const isArrowOrFn =
|
|
142
149
|
body.isKind(SyntaxKind.ArrowFunction) || body.isKind(SyntaxKind.FunctionExpression);
|
|
143
150
|
if (!isArrowOrFn) continue;
|
|
144
|
-
const hasAssertion = bodyHasAssertion(body, sf, 0, new
|
|
151
|
+
const hasAssertion = bodyHasAssertion(body, sf, 0, new Map());
|
|
145
152
|
if (!hasAssertion) {
|
|
146
153
|
const nameArg = args[0]?.getText().slice(0, 50) ?? "<anonymous>";
|
|
147
154
|
violations.push({
|
|
@@ -44,7 +44,7 @@ const RULES: Record<"de" | "es", LocaleRule> = {
|
|
|
44
44
|
// Tenants? covers plural; Organisation(en|s)? without trailing \b so
|
|
45
45
|
// compounds like Organisations-ID / Organisationsstruktur still match
|
|
46
46
|
// (infra#603). TenantAdmin stays safe: \b after Tenant fails on 'A'.
|
|
47
|
-
forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/],
|
|
47
|
+
forbidden: [/\bTenants?\b/i, /\bOrganisation(en|s)?/i],
|
|
48
48
|
},
|
|
49
49
|
es: {
|
|
50
50
|
preferred: "Organización",
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
compareToBaseline,
|
|
29
29
|
filesForGuard,
|
|
30
30
|
type GuardViolation,
|
|
31
|
+
relativeToCwd,
|
|
31
32
|
runStandalone,
|
|
32
33
|
type ScanSpec,
|
|
33
34
|
} from "./_lib/guard-kit";
|
|
@@ -66,7 +67,7 @@ function collectRawFormHtml(sf: SourceFile): RawFormFinding[] {
|
|
|
66
67
|
if (!RAW_FORM_TAGS.has(tag)) continue;
|
|
67
68
|
if (hasIgnoreTag(el, IGNORE_TAG)) continue;
|
|
68
69
|
findings.push({
|
|
69
|
-
file:
|
|
70
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
70
71
|
line: el.getStartLineNumber(),
|
|
71
72
|
tag,
|
|
72
73
|
});
|
|
@@ -146,7 +147,7 @@ function analyse(
|
|
|
146
147
|
if (!PRIMITIVE_NAME.test(c.name)) continue;
|
|
147
148
|
if (hasIgnoreTag(c.node, IGNORE_TAG)) continue;
|
|
148
149
|
violations.push({
|
|
149
|
-
file: sf.getFilePath(),
|
|
150
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
150
151
|
line: c.line,
|
|
151
152
|
message: `App-local UI primitive "${c.name}" — use a framework widget/primitive`,
|
|
152
153
|
});
|
|
@@ -34,7 +34,13 @@
|
|
|
34
34
|
// two; no usage site found at all, or any other mount kind, stays flagged.
|
|
35
35
|
|
|
36
36
|
import * as path from "node:path";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
Node,
|
|
39
|
+
type ObjectLiteralExpression,
|
|
40
|
+
type SourceFile,
|
|
41
|
+
SyntaxKind,
|
|
42
|
+
VariableDeclarationKind,
|
|
43
|
+
} from "ts-morph";
|
|
38
44
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
39
45
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
40
46
|
|
|
@@ -59,6 +65,34 @@ function isExternalSourceFile(sf: SourceFile): boolean {
|
|
|
59
65
|
return sf.getFilePath().includes("/node_modules/");
|
|
60
66
|
}
|
|
61
67
|
|
|
68
|
+
function stringConstantValue(node: Node): string | undefined {
|
|
69
|
+
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
|
70
|
+
return node.getLiteralText();
|
|
71
|
+
}
|
|
72
|
+
if (Node.isAsExpression(node)) return stringConstantValue(node.getExpression());
|
|
73
|
+
if (!Node.isIdentifier(node)) return undefined;
|
|
74
|
+
const symbol = node.getSymbol();
|
|
75
|
+
const resolved = symbol?.isAlias() ? symbol.getAliasedSymbol() : symbol;
|
|
76
|
+
for (const decl of resolved?.getDeclarations() ?? []) {
|
|
77
|
+
if (!Node.isVariableDeclaration(decl)) continue;
|
|
78
|
+
if (decl.getVariableStatement()?.getDeclarationKind() !== VariableDeclarationKind.Const) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const init = decl.getInitializer();
|
|
82
|
+
const value = init === undefined ? undefined : stringConstantValue(init);
|
|
83
|
+
if (value !== undefined) return value;
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Registry keys and `__component` usages meet on the resolved string value, so
|
|
89
|
+
// two features' same-named local consts don't collide and a literal never
|
|
90
|
+
// matches a computed key. An unresolvable identifier keeps a name-based key
|
|
91
|
+
// that no literal can equal, which leaves the component conservatively flagged.
|
|
92
|
+
function registryKeyFor(node: Node): string {
|
|
93
|
+
return stringConstantValue(node) ?? `unresolved:${node.getText()}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
62
96
|
function addRegistryMembers(
|
|
63
97
|
sf: SourceFile,
|
|
64
98
|
literal: ObjectLiteralExpression,
|
|
@@ -75,7 +109,7 @@ function addRegistryMembers(
|
|
|
75
109
|
if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
|
|
76
110
|
const nameNode = member.getNameNode();
|
|
77
111
|
const key = Node.isComputedPropertyName(nameNode)
|
|
78
|
-
? nameNode.getExpression()
|
|
112
|
+
? registryKeyFor(nameNode.getExpression())
|
|
79
113
|
: Node.isStringLiteral(nameNode)
|
|
80
114
|
? nameNode.getLiteralValue()
|
|
81
115
|
: member.getName();
|
|
@@ -292,11 +326,8 @@ function findComponentNameUsages(sf: SourceFile): { name: string; kind: MountKin
|
|
|
292
326
|
if (prop.getName() !== "__component") continue;
|
|
293
327
|
const init = prop.getInitializer();
|
|
294
328
|
if (init === undefined) continue;
|
|
295
|
-
const name =
|
|
296
|
-
|
|
297
|
-
: Node.isIdentifier(init)
|
|
298
|
-
? init.getText()
|
|
299
|
-
: undefined;
|
|
329
|
+
const name =
|
|
330
|
+
Node.isStringLiteral(init) || Node.isIdentifier(init) ? registryKeyFor(init) : undefined;
|
|
300
331
|
if (name === undefined) continue;
|
|
301
332
|
usages.push({ name, kind: mountKindFor(prop) });
|
|
302
333
|
}
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
// Part of App-Mounting 2.0 (infra#208).
|
|
6
6
|
|
|
7
7
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type AstGuard,
|
|
10
|
+
type GuardViolation,
|
|
11
|
+
relativeToCwd,
|
|
12
|
+
runStandalone,
|
|
13
|
+
type ScanSpec,
|
|
14
|
+
} from "./_lib/guard-kit";
|
|
9
15
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
10
16
|
|
|
11
17
|
const SCAN: ScanSpec = {
|
|
@@ -30,7 +36,7 @@ export const guard: AstGuard = {
|
|
|
30
36
|
if (attr.getNameNode().getText() !== "style") continue;
|
|
31
37
|
if (hasIgnoreTag(attr, IGNORE_TAG)) continue;
|
|
32
38
|
violations.push({
|
|
33
|
-
file: sf.getFilePath(),
|
|
39
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
34
40
|
line: attr.getStartLineNumber(),
|
|
35
41
|
message: "style= prop in app code (use theme tokens/widgets)",
|
|
36
42
|
});
|
|
@@ -45,7 +51,7 @@ export const guard: AstGuard = {
|
|
|
45
51
|
if (name !== "CSSProperties" && name !== "React.CSSProperties") continue;
|
|
46
52
|
if (hasIgnoreTag(ref, IGNORE_TAG)) continue;
|
|
47
53
|
violations.push({
|
|
48
|
-
file: sf.getFilePath(),
|
|
54
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
49
55
|
line: ref.getStartLineNumber(),
|
|
50
56
|
message: "CSSProperties style object in app code (use theme tokens/widgets)",
|
|
51
57
|
});
|
|
@@ -19,7 +19,13 @@ import {
|
|
|
19
19
|
type SourceFile,
|
|
20
20
|
SyntaxKind,
|
|
21
21
|
} from "ts-morph";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
type AstGuard,
|
|
24
|
+
type GuardViolation,
|
|
25
|
+
relativeToCwd,
|
|
26
|
+
runStandalone,
|
|
27
|
+
type ScanSpec,
|
|
28
|
+
} from "./_lib/guard-kit";
|
|
23
29
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
24
30
|
|
|
25
31
|
const SCAN: ScanSpec = {
|
|
@@ -134,7 +140,7 @@ export const guard: AstGuard = {
|
|
|
134
140
|
if (name === "" || !isViewLogic(name, fn)) continue;
|
|
135
141
|
if (hasIgnoreTag(fn, IGNORE_TAG)) continue;
|
|
136
142
|
violations.push({
|
|
137
|
-
file: sf.getFilePath(),
|
|
143
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
138
144
|
line: fn.getStartLineNumber(),
|
|
139
145
|
message: `View logic "${name}" belongs in lib/ (with a test) — web/ only components/hooks`,
|
|
140
146
|
});
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
// Part of App-Mounting 2.0 (infra#208).
|
|
12
12
|
|
|
13
13
|
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type AstGuard,
|
|
16
|
+
type GuardViolation,
|
|
17
|
+
relativeToCwd,
|
|
18
|
+
runStandalone,
|
|
19
|
+
type ScanSpec,
|
|
20
|
+
} from "./_lib/guard-kit";
|
|
15
21
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
16
22
|
|
|
17
23
|
// Match property/identifier names instead of call text — otherwise
|
|
@@ -40,7 +46,8 @@ export const guard: AstGuard = {
|
|
|
40
46
|
name: "No-Raw-Hooks Guard (App-Repos)",
|
|
41
47
|
scan: SCAN,
|
|
42
48
|
hint:
|
|
43
|
-
"Use the framework hook set: useQuery (live: true for SSE), useMutation, useDisclosure
|
|
49
|
+
"Use the framework hook set: useQuery (live: true for SSE), useMutation, useDisclosure, " +
|
|
50
|
+
"useReportStepComplete (wizard extension steps). " +
|
|
44
51
|
`Real special case (DOM integration or similar): // ${IGNORE_TAG} <reason>`,
|
|
45
52
|
run(files: readonly SourceFile[]) {
|
|
46
53
|
const violations: GuardViolation[] = [];
|
|
@@ -52,7 +59,7 @@ export const guard: AstGuard = {
|
|
|
52
59
|
if (BANNED_HOOKS.has(name)) {
|
|
53
60
|
if (hasIgnoreTag(call, IGNORE_TAG)) continue;
|
|
54
61
|
violations.push({
|
|
55
|
-
file: sf.getFilePath(),
|
|
62
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
56
63
|
line: call.getStartLineNumber(),
|
|
57
64
|
message: `${name} in App-Screen — use framework hooks (useQuery/useMutation/useDisclosure)`,
|
|
58
65
|
});
|
|
@@ -61,7 +68,7 @@ export const guard: AstGuard = {
|
|
|
61
68
|
if (name === "fetch") {
|
|
62
69
|
if (hasIgnoreTag(call, IGNORE_TAG)) continue;
|
|
63
70
|
violations.push({
|
|
64
|
-
file: sf.getFilePath(),
|
|
71
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
65
72
|
line: call.getStartLineNumber(),
|
|
66
73
|
message: "fetch() in App-Screen — use useQuery/useMutation or an API client (*.ts)",
|
|
67
74
|
});
|
|
@@ -167,10 +167,11 @@ function walk(dir: string, out: string[]): void {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
function buildTagPattern(tag: string): RegExp {
|
|
170
|
-
// Match `<tag` at the JSX opening: after
|
|
170
|
+
// Match `<tag` at the JSX opening: at line start or after a char that can't
|
|
171
|
+
// end an identifier/member access (`cond?<input/>:<div/>` hits, `Array<input` doesn't).
|
|
171
172
|
// Negative lookahead `[a-zA-Z0-9-]` so `<input` doesn't match `<inputfoo`.
|
|
172
173
|
// Doesn't match the closing tag (`</tag>`) — that would be redundant.
|
|
173
|
-
return new RegExp(`(^|[
|
|
174
|
+
return new RegExp(`(^|[^\\w$.])<${tag}(?![a-zA-Z0-9-])`, "u");
|
|
174
175
|
}
|
|
175
176
|
|
|
176
177
|
const TAG_PATTERNS = FORBIDDEN_TAGS.map((t) => ({ ...t, pattern: buildTagPattern(t.tag) }));
|
|
@@ -9,7 +9,13 @@
|
|
|
9
9
|
// respective migration PR (ui-guards input in _app-test.yml).
|
|
10
10
|
|
|
11
11
|
import { type JsxAttribute, type SourceFile, SyntaxKind } from "ts-morph";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
type AstGuard,
|
|
14
|
+
type GuardViolation,
|
|
15
|
+
relativeToCwd,
|
|
16
|
+
runStandalone,
|
|
17
|
+
type ScanSpec,
|
|
18
|
+
} from "./_lib/guard-kit";
|
|
13
19
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
14
20
|
|
|
15
21
|
const SCAN: ScanSpec = {
|
|
@@ -97,7 +103,7 @@ export const guard: AstGuard = {
|
|
|
97
103
|
const bad = offendingTokens(part.text);
|
|
98
104
|
if (bad.length === 0) continue;
|
|
99
105
|
violations.push({
|
|
100
|
-
file: sf.getFilePath(),
|
|
106
|
+
file: relativeToCwd(sf.getFilePath()),
|
|
101
107
|
line: part.line,
|
|
102
108
|
message: `design-bearing Tailwind classes in app code: ${bad.join(", ")}`,
|
|
103
109
|
});
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Guard: tests wait for a condition, not for time. Flags, in test files and
|
|
4
4
|
* Playwright specs/helpers:
|
|
5
|
-
* (a) `test.setTimeout(...)`, `test.slow(...)`
|
|
5
|
+
* (a) `test.setTimeout(...)`, `test.slow(...)`, `setDefaultTimeout(...)`,
|
|
6
|
+
* `*.describe.configure({ timeout })` — raising a timeout hides the
|
|
6
7
|
* cause of a slow or flaky test
|
|
7
8
|
* (b) `<page>.waitForTimeout(...)` — a fixed sleep instead of a condition
|
|
8
9
|
* (c) sleep loops — a `while`/`do`/`for`/`for…of`/`for…in` whose body calls
|
|
@@ -67,6 +68,8 @@ const LOOP_LABELS: ReadonlyMap<SyntaxKind, string> = new Map([
|
|
|
67
68
|
const SLEEP_CALLEES: ReadonlySet<string> = new Set(["sleep", "Bun.sleep"]);
|
|
68
69
|
const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout", "test.slow"]);
|
|
69
70
|
const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
|
|
71
|
+
const SET_DEFAULT_TIMEOUT_CALLEE = /(^|\.)setDefaultTimeout$/;
|
|
72
|
+
const DESCRIBE_CONFIGURE_CALLEE = /(^|\.)describe\.configure$/;
|
|
70
73
|
|
|
71
74
|
export interface Finding {
|
|
72
75
|
file: string;
|
|
@@ -127,10 +130,26 @@ function exceptionNote(node: Node): "allowed" | "incomplete" | "none" {
|
|
|
127
130
|
return nearby.some((text) => text.includes(EXCEPTION_TAG)) ? "incomplete" : "none";
|
|
128
131
|
}
|
|
129
132
|
|
|
133
|
+
function hasTimeoutProperty(node: Node | undefined): boolean {
|
|
134
|
+
return (
|
|
135
|
+
node !== undefined &&
|
|
136
|
+
Node.isObjectLiteralExpression(node) &&
|
|
137
|
+
node.getProperties().some((prop) => {
|
|
138
|
+
if (Node.isShorthandPropertyAssignment(prop) || Node.isPropertyAssignment(prop)) {
|
|
139
|
+
return prop.getName() === "timeout";
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
function raisedTimeoutReason(call: CallExpression): string | undefined {
|
|
131
147
|
const callee = call.getExpression();
|
|
132
148
|
const calleeText = callee.getText();
|
|
133
|
-
if (TIMEOUT_RAISING_CALLEES.has(calleeText)) {
|
|
149
|
+
if (TIMEOUT_RAISING_CALLEES.has(calleeText) || SET_DEFAULT_TIMEOUT_CALLEE.test(calleeText)) {
|
|
150
|
+
return `${calleeText}(…) raises the timeout instead of fixing the cause`;
|
|
151
|
+
}
|
|
152
|
+
if (DESCRIBE_CONFIGURE_CALLEE.test(calleeText) && call.getArguments().some(hasTimeoutProperty)) {
|
|
134
153
|
return `${calleeText}(…) raises the timeout instead of fixing the cause`;
|
|
135
154
|
}
|
|
136
155
|
if (Node.isPropertyAccessExpression(callee) && callee.getName() === "waitForTimeout") {
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
} from "ts-morph";
|
|
40
40
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
41
41
|
import { frameworkTsConfigPath } from "./_lib/roots";
|
|
42
|
-
import { type ScanSpec, scanFiles
|
|
42
|
+
import { keepsRootKind, type ScanSpec, scanFiles } from "./_lib/scan-scope";
|
|
43
43
|
|
|
44
44
|
const ROOT = process.cwd();
|
|
45
45
|
|
|
@@ -387,7 +387,7 @@ export const check: RepoCheck = {
|
|
|
387
387
|
// The kind filter (scan-scope.ts keepsRootKind) excludes "tooling" roots by
|
|
388
388
|
// default — that's a deliberate scope choice, not a vacuous scan, so it must
|
|
389
389
|
// report notApplicable instead of matchedFiles: 0.
|
|
390
|
-
if (
|
|
390
|
+
if (!roots.some((root) => keepsRootKind(SCAN, root))) {
|
|
391
391
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
392
392
|
}
|
|
393
393
|
|
package/src/pre-push.sh
CHANGED
|
@@ -123,7 +123,7 @@ fi
|
|
|
123
123
|
# canonical sibling path instead of this worktree — checking the wrong
|
|
124
124
|
# code. Run the worktree-local check only when scripts/check-wt.sh is
|
|
125
125
|
# tracked and executable (untracked/symlink stubs must not hijack push).
|
|
126
|
-
if [ "$(git rev-parse --git-dir)" != "$
|
|
126
|
+
if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
|
|
127
127
|
&& git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
|
|
128
128
|
&& [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
|
|
129
129
|
echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
|
|
@@ -153,7 +153,7 @@ run_guard_bin() {
|
|
|
153
153
|
# worktree's own package.json scripts directly instead (only when nested
|
|
154
154
|
# under the parent workspace; a standalone worktree's own `bun run test`
|
|
155
155
|
# already checks the right code).
|
|
156
|
-
if [ "$(git rev-parse --git-dir)" != "$
|
|
156
|
+
if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
|
|
157
157
|
&& [ -n "$PARENT_DIR" ]; then
|
|
158
158
|
cd "$REPO_ROOT"
|
|
159
159
|
echo "[pre-push] worktree without scripts/check-wt.sh — running this worktree's package.json scripts (typecheck, lint, test, test:dom) and guards (guards, checks, comment-lang)…"
|
package/src/run-guards.ts
CHANGED
|
@@ -121,6 +121,10 @@ export function runGuardsCli(argv: readonly string[], deps: RunGuardsCliDeps = {
|
|
|
121
121
|
console.error(flagsError);
|
|
122
122
|
return 1;
|
|
123
123
|
}
|
|
124
|
+
if (guardNameArg !== undefined && !flags.includes("--write-baseline")) {
|
|
125
|
+
console.error("--guard=<name> is only valid with --write-baseline");
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
124
128
|
if (flags.includes("--explain")) {
|
|
125
129
|
for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
|
|
126
130
|
console.log(line);
|