@cosmicdrift/kumiko-guards 0.334.0 → 0.336.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/framework-import.ts +29 -0
- package/src/_lib/git-env.ts +2 -10
- package/src/_lib/guard-kit.ts +12 -8
- package/src/_lib/scan-scope.ts +1 -1
- package/src/_lib/sql-inventory.ts +5 -1
- package/src/changes.json +26 -0
- package/src/check-as-casts.ts +89 -14
- package/src/check-real-provider-isolation.ts +9 -1
- package/src/check-runtime-isolation.ts +11 -4
- package/src/check-secret-literals.ts +18 -13
- package/src/cli.ts +4 -0
- package/src/guard-access-denied-test.ts +2 -2
- package/src/guard-admin-api.ts +1 -2
- package/src/guard-app-feature-structure.ts +8 -6
- package/src/guard-direct-fetch.ts +1 -1
- package/src/guard-error-reasons.ts +7 -3
- package/src/guard-escape-hatch-declared.ts +25 -13
- package/src/guard-fake-tests.ts +32 -25
- package/src/guard-i18n-keys.ts +0 -1
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-lib-test-coverage.ts +11 -6
- package/src/guard-no-custom-primitives.ts +3 -2
- package/src/guard-no-framed-extension-sections.ts +90 -26
- 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-open-to-all-reason.ts +2 -2
- package/src/guard-primitives-discipline.ts +3 -2
- package/src/guard-raw-classname.ts +8 -2
- package/src/guard-raw-sql.ts +9 -3
- package/src/guard-section-fields-raw.ts +81 -9
- package/src/guard-test-stack-drift.ts +11 -1
- package/src/guard-test-template-drift.ts +48 -2
- package/src/guard-test-timeouts.ts +21 -2
- package/src/guard-thin-wrappers.ts +2 -2
- package/src/guard-upgrade-state.ts +7 -4
- package/src/pre-push.sh +2 -2
- package/src/run-guards.ts +6 -1
- package/src/run-repo-checks.ts +1 -1
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +147 -25
|
@@ -46,10 +46,11 @@
|
|
|
46
46
|
* unresolved and the R2/R3 finding names that explicitly. The declaration
|
|
47
47
|
* does not propagate upward: it covers escalations inside that function's
|
|
48
48
|
* own body, not the function it is nested inside. This detection is purely
|
|
49
|
-
* lexical
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* lexical except for origin: `declareEscapeHatch` (and `withUnsafeRawGrant`
|
|
50
|
+
* for R2) must resolve to an import from `@cosmicdrift/kumiko-framework`
|
|
51
|
+
* (or, inside the framework package, a relative import into its `src`), so a
|
|
52
|
+
* same-named local function does not clear. `escapeHatch:` itself is a plain
|
|
53
|
+
* property and is never checked for origin. Referenced-by-variable functions, spread options, computed/string
|
|
53
54
|
* keys, and a non-literal, non-module-local-const `escapeHatch`/`reason`
|
|
54
55
|
* value are conservatively not recognized (miss, don't falsely clear).
|
|
55
56
|
*
|
|
@@ -68,7 +69,7 @@
|
|
|
68
69
|
*
|
|
69
70
|
* Usage:
|
|
70
71
|
* bun guards/guard-escape-hatch-declared.ts
|
|
71
|
-
* Baseline:
|
|
72
|
+
* Baseline: kumiko-guards guards --write-security-baseline
|
|
72
73
|
*/
|
|
73
74
|
import * as path from "node:path";
|
|
74
75
|
import {
|
|
@@ -77,7 +78,9 @@ import {
|
|
|
77
78
|
type PropertyAssignment,
|
|
78
79
|
type SourceFile,
|
|
79
80
|
SyntaxKind,
|
|
81
|
+
VariableDeclarationKind,
|
|
80
82
|
} from "ts-morph";
|
|
83
|
+
import { isFrameworkImportOf } from "./_lib/framework-import";
|
|
81
84
|
import { isGenericReason, resolveReasonText } from "./_lib/generic-reason";
|
|
82
85
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
83
86
|
|
|
@@ -230,7 +233,7 @@ function isExplicitUnsafeRawGrant(call: Node): boolean {
|
|
|
230
233
|
const receiver = expr.getExpression();
|
|
231
234
|
return (
|
|
232
235
|
receiver.isKind(SyntaxKind.CallExpression) &&
|
|
233
|
-
receiver.getExpression()
|
|
236
|
+
isFrameworkImportOf(receiver.getExpression(), "withUnsafeRawGrant")
|
|
234
237
|
);
|
|
235
238
|
}
|
|
236
239
|
|
|
@@ -387,9 +390,7 @@ function declareEscapeHatchReasonNode(stmt: Node): Node | undefined {
|
|
|
387
390
|
const expr = stmt.getExpression();
|
|
388
391
|
if (!expr.isKind(SyntaxKind.CallExpression)) return undefined;
|
|
389
392
|
const callee = expr.getExpression();
|
|
390
|
-
if (!
|
|
391
|
-
return undefined;
|
|
392
|
-
}
|
|
393
|
+
if (!isFrameworkImportOf(callee, "declareEscapeHatch")) return undefined;
|
|
393
394
|
const args = expr.getArguments();
|
|
394
395
|
const arg = args[0];
|
|
395
396
|
if (args.length !== 1 || !arg?.isKind(SyntaxKind.ObjectLiteralExpression)) return undefined;
|
|
@@ -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
|
|
@@ -471,7 +482,8 @@ function hasDeclaredEscapeHatchStatement(fn: Node): boolean {
|
|
|
471
482
|
body = fn.getBody();
|
|
472
483
|
}
|
|
473
484
|
if (!body?.isKind(SyntaxKind.Block)) return false;
|
|
474
|
-
|
|
485
|
+
const firstStatement = body.getStatements()[0];
|
|
486
|
+
return firstStatement !== undefined && isValidDeclareEscapeHatchCall(firstStatement);
|
|
475
487
|
}
|
|
476
488
|
|
|
477
489
|
function isInsideEscapeHatchDeclaredFunction(node: Node): boolean {
|
|
@@ -680,7 +692,7 @@ export function createEscapeHatchGuard(opts: { root: string }): AstGuard {
|
|
|
680
692
|
name: "Escape-Hatch-Declared Guard",
|
|
681
693
|
scan: SCAN,
|
|
682
694
|
security: true,
|
|
683
|
-
hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `
|
|
695
|
+
hint: 'Declare an escape hatch (r.systemScope() on the feature definition, .job.ts/r.job(...) for jobs, or { escapeHatch: { reason: "..." } } on the handler or hook), or remove the ctx.db.raw/unsafeRaw/queryAs|writeAs(system)/unsafeAllTenants access. Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
|
|
684
696
|
run(files) {
|
|
685
697
|
const violations: GuardViolation[] = [
|
|
686
698
|
...findGenericReasonCalls(files, opts.root).map((f) => ({
|
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({
|
package/src/guard-i18n-keys.ts
CHANGED
|
@@ -63,7 +63,6 @@ function collectUsedKeys(sf: SourceFile): UsedKey[] {
|
|
|
63
63
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
64
64
|
const exprText = call.getExpression().getText();
|
|
65
65
|
if (exprText !== "t" && !/(^|\.)t$/.test(exprText)) continue;
|
|
66
|
-
if (exprText === "test" || exprText === "expect") continue;
|
|
67
66
|
const args = call.getArguments();
|
|
68
67
|
if (args.length === 0) continue;
|
|
69
68
|
const first = args[0];
|
|
@@ -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",
|
|
@@ -92,7 +92,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
|
|
|
92
92
|
violations.push({
|
|
93
93
|
file: sf.getFilePath(),
|
|
94
94
|
line: prop.getStartLineNumber(),
|
|
95
|
-
message: `[${locale}]
|
|
95
|
+
message: `[${locale}] forbidden term "${hit}" in translation value — use "${rule.preferred}" (fw#2200).`,
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
}
|
|
@@ -104,7 +104,7 @@ export function findViolations(files: readonly SourceFile[]): GuardViolation[] {
|
|
|
104
104
|
export const guard: AstGuard = {
|
|
105
105
|
name: "i18n-Locale-Terminology Guard",
|
|
106
106
|
scan: SCAN,
|
|
107
|
-
hint: "DE: Mandant
|
|
107
|
+
hint: "DE: Mandant instead of Tenant/Organisation; ES: Organización instead of the tenant loanword. Roles like TenantAdmin are fine.",
|
|
108
108
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
109
109
|
const violations = findViolations(files)
|
|
110
110
|
.map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { dirname, resolve } from "node:path";
|
|
30
30
|
import { Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
31
31
|
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
32
|
+
import { escapeRegExp } from "./_lib/handler-name-forms";
|
|
32
33
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
33
34
|
|
|
34
35
|
// isTestFile() classifies by regex on the file's own path, not by which within-glob matched it, so lib/ vs. test classification stays correct regardless of scan-scope resolution.
|
|
@@ -116,16 +117,20 @@ function bodyTextWithoutImports(testSf: SourceFile): string {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
function referencesName(testSf: SourceFile, name: string): boolean {
|
|
119
|
-
|
|
120
|
+
// `\b` does not treat `$` as a word character, so identifiers like `format$`
|
|
121
|
+
// need explicit identifier-char lookarounds.
|
|
122
|
+
return new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`).test(
|
|
123
|
+
bodyTextWithoutImports(testSf),
|
|
124
|
+
);
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
export const guard: AstGuard = {
|
|
123
128
|
name: "Lib-Test-Coverage Guard (App-Repos)",
|
|
124
129
|
scan: SCAN,
|
|
125
130
|
hint:
|
|
126
|
-
"
|
|
127
|
-
"
|
|
128
|
-
`
|
|
131
|
+
"Every lib file with an exported function needs a test that imports from the " +
|
|
132
|
+
"module and references each function by name. IO loaders that an " +
|
|
133
|
+
`integration test covers: // ${IGNORE_TAG} <reason, e.g. integration-covered via …>`,
|
|
129
134
|
run(files: readonly SourceFile[]) {
|
|
130
135
|
const violations: GuardViolation[] = [];
|
|
131
136
|
const testFiles: SourceFile[] = [];
|
|
@@ -145,7 +150,7 @@ export const guard: AstGuard = {
|
|
|
145
150
|
violations.push({
|
|
146
151
|
file: sf.getFilePath(),
|
|
147
152
|
line: 1,
|
|
148
|
-
message: `
|
|
153
|
+
message: `No test imports this lib module (${relevant.length} exported function(s) untested)`,
|
|
149
154
|
});
|
|
150
155
|
continue;
|
|
151
156
|
}
|
|
@@ -155,7 +160,7 @@ export const guard: AstGuard = {
|
|
|
155
160
|
violations.push({
|
|
156
161
|
file: sf.getFilePath(),
|
|
157
162
|
line: e.node.getStartLineNumber(),
|
|
158
|
-
message: `
|
|
163
|
+
message: `Exported function "${e.name}" is not referenced by name in any linked test`,
|
|
159
164
|
});
|
|
160
165
|
}
|
|
161
166
|
}
|
|
@@ -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,32 +65,93 @@ 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
|
+
|
|
96
|
+
function addRegistryMembers(
|
|
97
|
+
sf: SourceFile,
|
|
98
|
+
literal: ObjectLiteralExpression,
|
|
99
|
+
entries: Map<string, string>,
|
|
100
|
+
visited: Set<Node>,
|
|
101
|
+
): void {
|
|
102
|
+
if (visited.has(literal)) return;
|
|
103
|
+
visited.add(literal);
|
|
104
|
+
for (const member of literal.getProperties()) {
|
|
105
|
+
// { NOTES_SECTION_EXTENSION_NAME: NotesSection } (PropertyAssignment,
|
|
106
|
+
// possibly computed-key) or { NotesSection } (ShorthandPropertyAssignment).
|
|
107
|
+
if (Node.isPropertyAssignment(member)) {
|
|
108
|
+
const valueInit = member.getInitializer();
|
|
109
|
+
if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
|
|
110
|
+
const nameNode = member.getNameNode();
|
|
111
|
+
const key = Node.isComputedPropertyName(nameNode)
|
|
112
|
+
? registryKeyFor(nameNode.getExpression())
|
|
113
|
+
: Node.isStringLiteral(nameNode)
|
|
114
|
+
? nameNode.getLiteralValue()
|
|
115
|
+
: member.getName();
|
|
116
|
+
entries.set(valueInit.getText(), key);
|
|
117
|
+
} else if (Node.isShorthandPropertyAssignment(member)) {
|
|
118
|
+
const name = member.getName();
|
|
119
|
+
entries.set(name, name);
|
|
120
|
+
} else if (Node.isSpreadAssignment(member)) {
|
|
121
|
+
addRegistryFromExpression(sf, member.getExpression(), entries, visited);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The registry may be inline, a same-file variable (`extensionSectionComponents:
|
|
127
|
+
// shared`, `{ extensionSectionComponents }`) or spread in (`{ ...shared }`).
|
|
128
|
+
function addRegistryFromExpression(
|
|
129
|
+
sf: SourceFile,
|
|
130
|
+
expression: Node,
|
|
131
|
+
entries: Map<string, string>,
|
|
132
|
+
visited: Set<Node>,
|
|
133
|
+
): void {
|
|
134
|
+
if (Node.isObjectLiteralExpression(expression)) {
|
|
135
|
+
addRegistryMembers(sf, expression, entries, visited);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!Node.isIdentifier(expression)) return;
|
|
139
|
+
const init = sf.getVariableDeclaration(expression.getText())?.getInitializer();
|
|
140
|
+
if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
|
|
141
|
+
}
|
|
142
|
+
|
|
62
143
|
/** Maps each registered component's identifier to the registry key usage sites reference via `__component: SOME_CONST`. */
|
|
63
144
|
function findExtensionComponentEntries(sf: SourceFile): Map<string, string> {
|
|
64
145
|
const entries = new Map<string, string>();
|
|
146
|
+
const visited = new Set<Node>();
|
|
65
147
|
for (const prop of sf.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
66
148
|
if (prop.getName() !== "extensionSectionComponents") continue;
|
|
67
149
|
const init = prop.getInitializer();
|
|
68
|
-
if (init
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const pa = member.asKindOrThrow(SyntaxKind.PropertyAssignment);
|
|
74
|
-
const valueInit = pa.getInitializer();
|
|
75
|
-
if (valueInit === undefined || valueInit.getKind() !== SyntaxKind.Identifier) continue;
|
|
76
|
-
const nameNode = pa.getNameNode();
|
|
77
|
-
const key = Node.isComputedPropertyName(nameNode)
|
|
78
|
-
? nameNode.getExpression().getText()
|
|
79
|
-
: Node.isStringLiteral(nameNode)
|
|
80
|
-
? nameNode.getLiteralValue()
|
|
81
|
-
: pa.getName();
|
|
82
|
-
entries.set(valueInit.getText(), key);
|
|
83
|
-
} else if (member.getKind() === SyntaxKind.ShorthandPropertyAssignment) {
|
|
84
|
-
const name = member.asKindOrThrow(SyntaxKind.ShorthandPropertyAssignment).getName();
|
|
85
|
-
entries.set(name, name);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
150
|
+
if (init !== undefined) addRegistryFromExpression(sf, init, entries, visited);
|
|
151
|
+
}
|
|
152
|
+
for (const prop of sf.getDescendantsOfKind(SyntaxKind.ShorthandPropertyAssignment)) {
|
|
153
|
+
if (prop.getName() !== "extensionSectionComponents") continue;
|
|
154
|
+
addRegistryFromExpression(sf, prop.getNameNode(), entries, visited);
|
|
88
155
|
}
|
|
89
156
|
return entries;
|
|
90
157
|
}
|
|
@@ -259,11 +326,8 @@ function findComponentNameUsages(sf: SourceFile): { name: string; kind: MountKin
|
|
|
259
326
|
if (prop.getName() !== "__component") continue;
|
|
260
327
|
const init = prop.getInitializer();
|
|
261
328
|
if (init === undefined) continue;
|
|
262
|
-
const name =
|
|
263
|
-
|
|
264
|
-
: Node.isIdentifier(init)
|
|
265
|
-
? init.getText()
|
|
266
|
-
: undefined;
|
|
329
|
+
const name =
|
|
330
|
+
Node.isStringLiteral(init) || Node.isIdentifier(init) ? registryKeyFor(init) : undefined;
|
|
267
331
|
if (name === undefined) continue;
|
|
268
332
|
usages.push({ name, kind: mountKindFor(prop) });
|
|
269
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
|
});
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
14
|
* bun guards/guard-open-to-all-reason.ts
|
|
15
|
-
* Baseline:
|
|
15
|
+
* Baseline: kumiko-guards guards --write-security-baseline
|
|
16
16
|
*/
|
|
17
17
|
import * as path from "node:path";
|
|
18
18
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -85,7 +85,7 @@ export function createOpenToAllReasonGuard(opts: { root: string }): AstGuard {
|
|
|
85
85
|
name: "Open-To-All-Reason Guard",
|
|
86
86
|
scan: SCAN,
|
|
87
87
|
security: true,
|
|
88
|
-
hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `
|
|
88
|
+
hint: 'Provide openToAll: { reason: "<why any authenticated user may call this>" } (+ personalData: "tenant-members" for write handlers with unbound personal data). Baseline after a deliberate reduction: `kumiko-guards guards --write-security-baseline`',
|
|
89
89
|
run(files) {
|
|
90
90
|
const violations: GuardViolation[] = [
|
|
91
91
|
...findGenericOpenToAllReasons(files, opts.root).map((f) => ({
|
|
@@ -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
|
});
|
package/src/guard-raw-sql.ts
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
16
16
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
BLOCKING_SQL_KINDS,
|
|
19
|
+
isSqlScanExcluded,
|
|
20
|
+
scanRepo,
|
|
21
|
+
sqlScanDirsFor,
|
|
22
|
+
} from "./_lib/sql-inventory";
|
|
18
23
|
|
|
19
24
|
export type RawSqlFinding = {
|
|
20
25
|
readonly repo: string;
|
|
@@ -65,8 +70,9 @@ export const check: RepoCheck = {
|
|
|
65
70
|
"Rule: runtime SQL only in db/queries/*, bun-db/query.ts, testing/*, or with " +
|
|
66
71
|
"// kumiko-lint-ignore raw-sql <reason> on the line or the line above.",
|
|
67
72
|
async run(roots) {
|
|
68
|
-
//
|
|
69
|
-
|
|
73
|
+
// Only the deliberate exclusion is skipped; a repo whose declared source roots
|
|
74
|
+
// are all missing stays in the scan so 0 files surfaces as vacuous.
|
|
75
|
+
const applicableRoots = roots.filter((r) => !isSqlScanExcluded(r));
|
|
70
76
|
if (applicableRoots.length === 0) {
|
|
71
77
|
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
72
78
|
}
|