@cosmicdrift/kumiko-guards 0.1.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/LICENSE +57 -0
- package/README.md +16 -0
- package/package.json +40 -0
- package/src/_lib/baseline-compare.ts +56 -0
- package/src/_lib/generic-reason.ts +39 -0
- package/src/_lib/guard-kit.ts +534 -0
- package/src/_lib/handler-name-forms.ts +29 -0
- package/src/_lib/ignore-tag.ts +24 -0
- package/src/_lib/primitives-access.ts +19 -0
- package/src/_lib/roots.ts +304 -0
- package/src/_lib/scan-lines.ts +25 -0
- package/src/_lib/scan-scope.ts +152 -0
- package/src/_lib/security-baseline-cli.ts +54 -0
- package/src/_lib/security-baseline.ts +325 -0
- package/src/_lib/sql-inventory.ts +267 -0
- package/src/guard-access-denied-test.ts +135 -0
- package/src/guard-admin-api.ts +134 -0
- package/src/guard-cross-feature-imports.ts +244 -0
- package/src/guard-direct-entity-writes.ts +387 -0
- package/src/guard-direct-fetch.ts +154 -0
- package/src/guard-escape-hatch-declared.ts +520 -0
- package/src/guard-fake-tests.ts +137 -0
- package/src/guard-html-escape.ts +345 -0
- package/src/guard-no-custom-primitives.ts +196 -0
- package/src/guard-no-date-api.ts +186 -0
- package/src/guard-no-direct-fs.ts +232 -0
- package/src/guard-no-direct-process-env.ts +126 -0
- package/src/guard-no-inline-styles.ts +58 -0
- package/src/guard-no-logic-in-views.ts +147 -0
- package/src/guard-no-raw-hooks.ts +76 -0
- package/src/guard-open-to-all-reason.ts +112 -0
- package/src/guard-pre-es-patterns.ts +199 -0
- package/src/guard-primitives-discipline.ts +330 -0
- package/src/guard-raw-classname.ts +111 -0
- package/src/guard-raw-interactive-elements.ts +154 -0
- package/src/guard-raw-sql.ts +89 -0
- package/src/guard-renderer-boundaries.ts +157 -0
- package/src/guard-restricted-symbols.ts +138 -0
- package/src/guard-silent-skip.ts +186 -0
- package/src/guard-tailwind-scan-surface.ts +588 -0
- package/src/guard-tenant-escalation.ts +312 -0
- package/src/guard-thin-wrappers.ts +422 -0
- package/src/guard-unsafe-json-parse.ts +86 -0
- package/src/index.ts +29 -0
- package/src/run-guards.ts +78 -0
- package/src/run-repo-checks.ts +22 -0
- package/src/run-ui-guards.ts +25 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: role-restricted write handlers need an access-denied test.
|
|
4
|
+
*
|
|
5
|
+
* Rule: any `defineWriteHandler`/`*.writeHandler` call whose `access` object
|
|
6
|
+
* has a `roles` property (and no `openToAll`, and no anonymous/all role) must
|
|
7
|
+
* have a test somewhere in the repo that mentions the handler AND asserts a
|
|
8
|
+
* rejection (AccessDeniedError / access_denied / 403). Matching is file-level
|
|
9
|
+
* and within the handler's own repo, same contract as guard-tenant-escalation.
|
|
10
|
+
*
|
|
11
|
+
* Tripwire: a false-negative (a weak name match hiding a real gap) is
|
|
12
|
+
* tolerated; a false-positive on a clean repo is not.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* bun guards/guard-access-denied-test.ts
|
|
16
|
+
* Baseline: bun guards/run-guards.ts --write-security-baseline
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
21
|
+
import { findRepoRootFor } from "./_lib/baseline-compare";
|
|
22
|
+
import { type AstGuard, type GuardViolation, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
23
|
+
import {
|
|
24
|
+
escapeRegExp,
|
|
25
|
+
literalStringOf,
|
|
26
|
+
mentionsAsWord,
|
|
27
|
+
nameForms,
|
|
28
|
+
} from "./_lib/handler-name-forms";
|
|
29
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
30
|
+
|
|
31
|
+
const ROOT = process.cwd();
|
|
32
|
+
const SCAN: ScanSpec = {
|
|
33
|
+
scope: "source",
|
|
34
|
+
extensions: ["ts"],
|
|
35
|
+
frameworkWithin: ["packages/*/src/**", "samples/**"],
|
|
36
|
+
};
|
|
37
|
+
const TEST_FILE = /\.test\.ts$/;
|
|
38
|
+
|
|
39
|
+
// Any role that lets an unauthenticated/unrestricted caller through means
|
|
40
|
+
// nobody is actually excluded by this handler's roles.
|
|
41
|
+
const OPEN_ROLE_RE = /["'`](?:anonymous|all)["'`]|\baccess\.(?:all|anonymous)\b/;
|
|
42
|
+
|
|
43
|
+
type RoleRestrictedHandler = { name: string; file: string; line: number };
|
|
44
|
+
|
|
45
|
+
export function findRoleRestrictedWriteHandlers(
|
|
46
|
+
files: readonly SourceFile[],
|
|
47
|
+
): RoleRestrictedHandler[] {
|
|
48
|
+
const out: RoleRestrictedHandler[] = [];
|
|
49
|
+
for (const sf of files) {
|
|
50
|
+
if (TEST_FILE.test(sf.getFilePath())) continue;
|
|
51
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
52
|
+
const callee = call.getExpression().getText();
|
|
53
|
+
if (callee !== "defineWriteHandler" && !/\.writeHandler$/.test(callee)) continue;
|
|
54
|
+
const arg = call.getArguments()[0];
|
|
55
|
+
if (!arg || arg.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
56
|
+
const obj = arg.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
57
|
+
const name = literalStringOf(obj.getProperty("name"));
|
|
58
|
+
if (!name) continue;
|
|
59
|
+
const accessProp = obj.getProperty("access");
|
|
60
|
+
if (!accessProp || accessProp.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
61
|
+
const accessInit = accessProp.asKindOrThrow(SyntaxKind.PropertyAssignment).getInitializer();
|
|
62
|
+
if (!accessInit || accessInit.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
63
|
+
const accessObj = accessInit.asKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
64
|
+
if (accessObj.getProperty("openToAll")) continue;
|
|
65
|
+
const rolesProp = accessObj.getProperty("roles");
|
|
66
|
+
if (!rolesProp) continue;
|
|
67
|
+
if (
|
|
68
|
+
rolesProp.getKind() !== SyntaxKind.PropertyAssignment &&
|
|
69
|
+
rolesProp.getKind() !== SyntaxKind.ShorthandPropertyAssignment
|
|
70
|
+
)
|
|
71
|
+
continue;
|
|
72
|
+
if (OPEN_ROLE_RE.test(rolesProp.getText())) continue;
|
|
73
|
+
out.push({ name, file: sf.getFilePath(), line: call.getStartLineNumber() });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const ACCESS_DENIED_ASSERTION = /\bAccessDeniedError\b|\baccess_denied\b|\b403\b/;
|
|
80
|
+
|
|
81
|
+
const SHORT_WORD = /^[a-z0-9]+$/;
|
|
82
|
+
|
|
83
|
+
// A single lowercase word ("create", "approve") is common enough in
|
|
84
|
+
// unrelated test scaffolding (createTestStack, approveAll) that a bare word-
|
|
85
|
+
// boundary match is too loose — only count it as a string-literal end
|
|
86
|
+
// ("approve"/"feat:write:approve") or a property access (Handlers.approve).
|
|
87
|
+
export function testMentionsHandler(text: string, handlerName: string): boolean {
|
|
88
|
+
return nameForms(handlerName).some((form) => {
|
|
89
|
+
if (!SHORT_WORD.test(form)) return mentionsAsWord(text, form);
|
|
90
|
+
const esc = escapeRegExp(form);
|
|
91
|
+
return new RegExp(`[:"'\`]${esc}["'\`]|\\.${esc}\\b`).test(text);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function findHandlersWithoutAccessDeniedTest(
|
|
96
|
+
files: readonly SourceFile[],
|
|
97
|
+
roots: readonly { readonly absPath: string }[] = [],
|
|
98
|
+
): RoleRestrictedHandler[] {
|
|
99
|
+
const repoKeyOf = (filePath: string): string => findRepoRootFor(filePath, roots)?.absPath ?? "";
|
|
100
|
+
const testTextsByRepo = new Map<string, string[]>();
|
|
101
|
+
for (const sf of files) {
|
|
102
|
+
if (!TEST_FILE.test(sf.getFilePath())) continue;
|
|
103
|
+
const text = sf.getFullText();
|
|
104
|
+
if (!ACCESS_DENIED_ASSERTION.test(text)) continue;
|
|
105
|
+
const repoKey = repoKeyOf(sf.getFilePath());
|
|
106
|
+
const texts = testTextsByRepo.get(repoKey) ?? [];
|
|
107
|
+
texts.push(text);
|
|
108
|
+
testTextsByRepo.set(repoKey, texts);
|
|
109
|
+
}
|
|
110
|
+
return findRoleRestrictedWriteHandlers(files).filter((h) => {
|
|
111
|
+
const testTexts = testTextsByRepo.get(repoKeyOf(h.file)) ?? [];
|
|
112
|
+
return !testTexts.some((text) => testMentionsHandler(text, h.name));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const guard: AstGuard = {
|
|
117
|
+
name: "Access-Denied-Test Guard",
|
|
118
|
+
scan: SCAN,
|
|
119
|
+
security: true,
|
|
120
|
+
hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `bun guards/run-guards.ts --write-security-baseline`.",
|
|
121
|
+
run(files) {
|
|
122
|
+
// consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
|
|
123
|
+
const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(
|
|
124
|
+
files,
|
|
125
|
+
resolveRepoRoots(),
|
|
126
|
+
).map((h) => ({
|
|
127
|
+
file: path.relative(ROOT, h.file),
|
|
128
|
+
line: h.line,
|
|
129
|
+
message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
|
|
130
|
+
}));
|
|
131
|
+
return { violations };
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: blocks calls to the event-store admin API (`appendRaw`/`appendRawBatch`)
|
|
4
|
+
* outside allowed paths.
|
|
5
|
+
*
|
|
6
|
+
* The admin API is a Marten bypass for legacy data imports (prod-readiness
|
|
7
|
+
* wave 3, step 3.1). It BYPASSES the pipeline — no projections, no
|
|
8
|
+
* postSave hooks, no SSE/search/audit. Used accidentally from application
|
|
9
|
+
* code, that causes state inconsistencies that surface very late (at the
|
|
10
|
+
* next projection rebuild, or never).
|
|
11
|
+
*
|
|
12
|
+
* Second line of defense next to the deep-import path: even if someone pulls
|
|
13
|
+
* `@kubiko/framework/event-store/admin-api` directly, this guard catches it
|
|
14
|
+
* at the next `bun kumiko check`.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* bun guards/guard-admin-api.ts
|
|
18
|
+
*
|
|
19
|
+
* Exit 1 if violations found, 0 if clean.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { type CallExpression, type Identifier, type SourceFile, SyntaxKind } from "ts-morph";
|
|
24
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
25
|
+
|
|
26
|
+
const ROOT = process.cwd();
|
|
27
|
+
|
|
28
|
+
const SCAN: ScanSpec = { scope: "source", extensions: ["ts"] };
|
|
29
|
+
|
|
30
|
+
// Test files may use the API freely — they are the primary verifiers.
|
|
31
|
+
// App code is never shipped through tests.
|
|
32
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
33
|
+
|
|
34
|
+
// Allowed callers: migration runners (sample-local, src/ and bin/) +
|
|
35
|
+
// admin scripts + the definition itself + this guard script.
|
|
36
|
+
const ALLOWLIST: readonly RegExp[] = [
|
|
37
|
+
/^samples\/[^/]+\/src\/migration\//,
|
|
38
|
+
/^scripts\/migrations\//,
|
|
39
|
+
/^packages\/framework\/src\/event-store\/admin-api\.ts$/,
|
|
40
|
+
/^scripts\/guard-admin-api\.ts$/,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const GUARDED_CALLS = new Set(["appendRaw", "appendRawBatch"]);
|
|
44
|
+
|
|
45
|
+
export interface Violation {
|
|
46
|
+
file: string;
|
|
47
|
+
line: number;
|
|
48
|
+
functionName: string;
|
|
49
|
+
enclosingFunction: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectViolations(sourceFile: SourceFile): Violation[] {
|
|
53
|
+
const violations: Violation[] = [];
|
|
54
|
+
const relativePath = path.relative(ROOT, sourceFile.getFilePath());
|
|
55
|
+
if (isAllowed(relativePath)) return violations;
|
|
56
|
+
|
|
57
|
+
const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
|
|
58
|
+
for (const call of calls) {
|
|
59
|
+
const fnName = getCalleeName(call);
|
|
60
|
+
if (!fnName || !GUARDED_CALLS.has(fnName)) continue;
|
|
61
|
+
violations.push({
|
|
62
|
+
file: relativePath,
|
|
63
|
+
line: call.getStartLineNumber(),
|
|
64
|
+
functionName: fnName,
|
|
65
|
+
enclosingFunction: findEnclosingName(call),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return violations;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isAllowed(relativePath: string): boolean {
|
|
72
|
+
return ALLOWLIST.some((re) => re.test(relativePath));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Extract the called name. Handles:
|
|
76
|
+
// appendRaw(...) → "appendRaw" (Identifier)
|
|
77
|
+
// someNamespace.appendRaw(...) → "appendRaw" (PropertyAccessExpression)
|
|
78
|
+
// Any other callee shape returns null — we only want bare-name / qualified-name
|
|
79
|
+
// references to the known function names.
|
|
80
|
+
function getCalleeName(call: CallExpression): string | null {
|
|
81
|
+
const expr = call.getExpression();
|
|
82
|
+
if (expr.getKind() === SyntaxKind.Identifier) {
|
|
83
|
+
return (expr as Identifier).getText();
|
|
84
|
+
}
|
|
85
|
+
if (expr.getKind() === SyntaxKind.PropertyAccessExpression) {
|
|
86
|
+
const name = expr.asKindOrThrow(SyntaxKind.PropertyAccessExpression).getName();
|
|
87
|
+
return name;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findEnclosingName(call: CallExpression): string {
|
|
93
|
+
let cur = call.getParent();
|
|
94
|
+
while (cur) {
|
|
95
|
+
if (cur.isKind(SyntaxKind.FunctionDeclaration) || cur.isKind(SyntaxKind.MethodDeclaration)) {
|
|
96
|
+
return cur.getName() ?? "<anonymous>";
|
|
97
|
+
}
|
|
98
|
+
if (cur.isKind(SyntaxKind.FunctionExpression) || cur.isKind(SyntaxKind.ArrowFunction)) {
|
|
99
|
+
const parent = cur.getParent();
|
|
100
|
+
if (parent?.isKind(SyntaxKind.VariableDeclaration)) return parent.getName();
|
|
101
|
+
if (parent?.isKind(SyntaxKind.PropertyAssignment)) return parent.getName();
|
|
102
|
+
return "<anonymous>";
|
|
103
|
+
}
|
|
104
|
+
cur = cur.getParent();
|
|
105
|
+
}
|
|
106
|
+
return "<top-level>";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const guard: AstGuard = {
|
|
110
|
+
name: "Admin-API Guard",
|
|
111
|
+
scan: SCAN,
|
|
112
|
+
// App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline there too (infra#502).
|
|
113
|
+
security: true,
|
|
114
|
+
hint: "Admin-API (appendRaw/appendRawBatch) umgeht die Pipeline — erlaubt nur in samples/*/migration/ oder scripts/migrations/. Für Domain-Events: ctx.appendEvent / write-Handler.",
|
|
115
|
+
run(files) {
|
|
116
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
117
|
+
|
|
118
|
+
for (const sf of files) {
|
|
119
|
+
const file = sf.getFilePath();
|
|
120
|
+
if (EXCLUDE.test(file)) continue;
|
|
121
|
+
for (const v of collectViolations(sf)) {
|
|
122
|
+
violations.push({
|
|
123
|
+
file: v.file,
|
|
124
|
+
line: v.line,
|
|
125
|
+
message: `${v.functionName}(...) in ${v.enclosingFunction}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { violations };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (import.meta.main) runStandalone(guard);
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: blocks cross-feature deep imports.
|
|
4
|
+
*
|
|
5
|
+
* R1 from docs/plans/architecture/lint-rules.md.
|
|
6
|
+
*
|
|
7
|
+
* What's enforced:
|
|
8
|
+
* - A file inside feature A may not import from feature B's internals.
|
|
9
|
+
* - Allowed cross-feature imports: the barrel (`from "../B"`, or
|
|
10
|
+
* `"../B/index"` / `"../B/index.ts"`), and two side-effect-free public
|
|
11
|
+
* surface modules — `"../B/contract"` and `"../B/schema/entity"` (with
|
|
12
|
+
* or without extension). Anything else — `from "../B/types"`,
|
|
13
|
+
* `from "../B/handlers/foo"`, `from "../B/feature"` — is rejected.
|
|
14
|
+
* - Same-feature relative imports (`./x`, `../sibling-in-same-feature`)
|
|
15
|
+
* stay unrestricted.
|
|
16
|
+
* - Non-relative imports (`@cosmicdrift/kumiko-framework`, `drizzle-orm`, `zod`)
|
|
17
|
+
* stay unrestricted.
|
|
18
|
+
*
|
|
19
|
+
* Why: deep imports couple feature B's file layout to feature A's call
|
|
20
|
+
* sites. A rename inside B silently breaks A. The barrel is B's public
|
|
21
|
+
* contract — A only sees what B chose to export.
|
|
22
|
+
*
|
|
23
|
+
* Feature-boundary inference:
|
|
24
|
+
* - packages/bundled-features/src/<feature>/...
|
|
25
|
+
* - samples/<sample>/src/features/<feature>/...
|
|
26
|
+
*
|
|
27
|
+
* Files outside those layouts (e.g. samples/<sample>/src/feature.ts for
|
|
28
|
+
* single-feature samples, framework internals) have no feature boundary
|
|
29
|
+
* and are skipped.
|
|
30
|
+
*
|
|
31
|
+
* Escape hatch: `// kumiko-lint-ignore cross-feature-import [reason]`
|
|
32
|
+
* on the same line as the import, or on the line directly above.
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* bun guards/guard-cross-feature-imports.ts
|
|
36
|
+
*
|
|
37
|
+
* Exit 1 on violations, 0 when clean.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import * as path from "node:path";
|
|
41
|
+
import type { SourceFile } from "ts-morph";
|
|
42
|
+
import { type AstGuard, runStandalone, type ScanSpec } from "./_lib/guard-kit";
|
|
43
|
+
|
|
44
|
+
const ROOT = process.cwd();
|
|
45
|
+
|
|
46
|
+
const SCAN: ScanSpec = {
|
|
47
|
+
scope: "source",
|
|
48
|
+
extensions: ["ts"],
|
|
49
|
+
frameworkWithin: ["packages/*/src/**", "samples/recipes/*/src/**", "samples/apps/*/src/**"],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$|\.g\.ts$)/;
|
|
53
|
+
|
|
54
|
+
// Files with an explicit, documented exception. Each entry must come with a
|
|
55
|
+
// TODO naming the planned refactor — the allowlist exists to turn the guard
|
|
56
|
+
// on without blocking the merge, not as a permanent home.
|
|
57
|
+
const ALLOWLIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [];
|
|
58
|
+
|
|
59
|
+
const IGNORE_TAG = "kumiko-lint-ignore cross-feature-import";
|
|
60
|
+
|
|
61
|
+
interface Violation {
|
|
62
|
+
file: string;
|
|
63
|
+
line: number;
|
|
64
|
+
importPath: string;
|
|
65
|
+
importedFeature: string;
|
|
66
|
+
fileFeature: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type FeatureLocation = {
|
|
70
|
+
// Repo-relative path of the feature's root directory. Two files resolve
|
|
71
|
+
// to the same boundary iff they live under the same root.
|
|
72
|
+
readonly root: string;
|
|
73
|
+
readonly name: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Identifies the feature a file belongs to. Returns null for files that
|
|
77
|
+
// live outside any feature boundary (single-feature sample roots,
|
|
78
|
+
// framework-internal helpers reached via the same scan).
|
|
79
|
+
function locateFeature(absPath: string): FeatureLocation | null {
|
|
80
|
+
const rel = path.relative(ROOT, absPath);
|
|
81
|
+
|
|
82
|
+
const coreMatch = rel.match(/^(packages\/bundled-features\/src\/([^/]+))\//);
|
|
83
|
+
if (coreMatch?.[1] && coreMatch[2]) {
|
|
84
|
+
return { root: coreMatch[1], name: coreMatch[2] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Other packages/<pkg>/src layouts (framework internals, enterprise
|
|
88
|
+
// packages): the package itself is the feature boundary — enterprise
|
|
89
|
+
// ships one feature per package (packages/ai-conversation/src/
|
|
90
|
+
// feature.ts), framework packages outside bundled-features have no
|
|
91
|
+
// sub-feature concept. Safe superset: never flags same-package imports,
|
|
92
|
+
// only catches genuine cross-package deep-relative imports — which
|
|
93
|
+
// shouldn't exist anyway since packages are consumed as separate npm
|
|
94
|
+
// packages via bare specifiers, not relative paths.
|
|
95
|
+
const pkgMatch = rel.match(/^(packages\/([^/]+)\/src)\//);
|
|
96
|
+
if (pkgMatch?.[1] && pkgMatch[2]) {
|
|
97
|
+
return { root: pkgMatch[1], name: pkgMatch[2] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sampleMatch = rel.match(/^(samples\/[^/]+\/src\/features\/([^/]+))(\/|$)/);
|
|
101
|
+
if (sampleMatch?.[1] && sampleMatch[2]) {
|
|
102
|
+
return { root: sampleMatch[1], name: sampleMatch[2] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Flat-layout app repos (studio, publicstatus, show-pony, money-horse,
|
|
106
|
+
// phronexsis, offlot-app, solon): src/features/<feature>/...
|
|
107
|
+
const appMatch = rel.match(/^(src\/features\/([^/]+))(\/|$)/);
|
|
108
|
+
if (appMatch?.[1] && appMatch[2]) {
|
|
109
|
+
return { root: appMatch[1], name: appMatch[2] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isAllowlisted(filePath: string): { allowed: true; reason: string } | { allowed: false } {
|
|
116
|
+
const rel = path.relative(ROOT, filePath);
|
|
117
|
+
for (const entry of ALLOWLIST) {
|
|
118
|
+
if (entry.pattern.test(rel)) return { allowed: true, reason: entry.reason };
|
|
119
|
+
}
|
|
120
|
+
return { allowed: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Kumiko's feature barrel (feature.ts) doubles as the registration module
|
|
124
|
+
// (mounts handlers/entities) — importing it pulls in the whole server
|
|
125
|
+
// graph and creates require cycles. These two are side-effect-free
|
|
126
|
+
// declaration modules (contract types, entity definition) that exist
|
|
127
|
+
// precisely to be read by other features, so they're allowed alongside the
|
|
128
|
+
// barrel. Closed set, not "everything but the barrel": `feature`/
|
|
129
|
+
// `feature.ts` must stay blocked, that's the registration module the guard
|
|
130
|
+
// exists to protect.
|
|
131
|
+
const PUBLIC_SURFACE_MODULES = new Set([
|
|
132
|
+
"contract",
|
|
133
|
+
"contract.ts",
|
|
134
|
+
"contract.tsx",
|
|
135
|
+
"schema/entity",
|
|
136
|
+
"schema/entity.ts",
|
|
137
|
+
"schema/entity.tsx",
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
// True when the resolved import path points inside `featureRoot` AND
|
|
141
|
+
// addresses something deeper than the barrel index. The barrel itself
|
|
142
|
+
// (a) bare `featureRoot` directory or (b) `featureRoot/index` is the
|
|
143
|
+
// public contract — anything else is an internal.
|
|
144
|
+
function isDeepImport(resolvedAbs: string, featureRoot: string): boolean {
|
|
145
|
+
const featureRootAbs = path.join(ROOT, featureRoot);
|
|
146
|
+
const rel = path.relative(featureRootAbs, resolvedAbs);
|
|
147
|
+
if (
|
|
148
|
+
rel === "" ||
|
|
149
|
+
rel === "index" ||
|
|
150
|
+
rel === "index.ts" ||
|
|
151
|
+
rel === "index.tsx" ||
|
|
152
|
+
PUBLIC_SURFACE_MODULES.has(rel)
|
|
153
|
+
) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
// Same-folder index without extension lookup ends up as "index" — keep
|
|
157
|
+
// that allowed; everything else (`types`, `handlers/foo`, etc.) is deep.
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Resolve a relative import to its absolute on-disk path. ts-morph's
|
|
162
|
+
// SourceFile.getModuleSpecifierSourceFile() walks the same resolution the
|
|
163
|
+
// compiler uses; we use it so the guard agrees with TS exactly.
|
|
164
|
+
function resolveImport(sf: SourceFile, specifier: string): string | null {
|
|
165
|
+
if (!specifier.startsWith(".")) return null;
|
|
166
|
+
const importDecl = sf
|
|
167
|
+
.getImportDeclarations()
|
|
168
|
+
.find((d) => d.getModuleSpecifierValue() === specifier);
|
|
169
|
+
if (!importDecl) return null;
|
|
170
|
+
const target = importDecl.getModuleSpecifierSourceFile();
|
|
171
|
+
if (target) return target.getFilePath();
|
|
172
|
+
// The import may resolve to a barrel directory whose index.ts ts-morph
|
|
173
|
+
// didn't add to the project. Fall back to a manual join + .ts probe.
|
|
174
|
+
return path.resolve(path.dirname(sf.getFilePath()), specifier);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isIgnored(sf: SourceFile, importLine: number): boolean {
|
|
178
|
+
const text = sf.getFullText();
|
|
179
|
+
const lines = text.split("\n");
|
|
180
|
+
const onLine = lines[importLine - 1] ?? "";
|
|
181
|
+
if (onLine.includes(IGNORE_TAG)) return true;
|
|
182
|
+
const above = lines[importLine - 2] ?? "";
|
|
183
|
+
return above.trim().startsWith("//") && above.includes(IGNORE_TAG);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findCrossFeatureViolations(sf: SourceFile): Omit<Violation, "file">[] {
|
|
187
|
+
const fileFeature = locateFeature(sf.getFilePath());
|
|
188
|
+
if (!fileFeature) return [];
|
|
189
|
+
|
|
190
|
+
const violations: Omit<Violation, "file">[] = [];
|
|
191
|
+
|
|
192
|
+
for (const importDecl of sf.getImportDeclarations()) {
|
|
193
|
+
const specifier = importDecl.getModuleSpecifierValue();
|
|
194
|
+
if (!specifier.startsWith(".")) continue;
|
|
195
|
+
|
|
196
|
+
const resolved = resolveImport(sf, specifier);
|
|
197
|
+
if (!resolved) continue;
|
|
198
|
+
|
|
199
|
+
const importedFeature = locateFeature(resolved);
|
|
200
|
+
if (!importedFeature) continue;
|
|
201
|
+
if (importedFeature.root === fileFeature.root) continue;
|
|
202
|
+
|
|
203
|
+
if (!isDeepImport(resolved, importedFeature.root)) continue;
|
|
204
|
+
|
|
205
|
+
const line = importDecl.getStartLineNumber();
|
|
206
|
+
if (isIgnored(sf, line)) continue;
|
|
207
|
+
|
|
208
|
+
violations.push({
|
|
209
|
+
line,
|
|
210
|
+
importPath: specifier,
|
|
211
|
+
importedFeature: importedFeature.name,
|
|
212
|
+
fileFeature: fileFeature.name,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return violations;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const guard: AstGuard = {
|
|
220
|
+
name: "Cross-Feature-Import Guard",
|
|
221
|
+
scan: SCAN,
|
|
222
|
+
hint: `Import from the feature's barrel ("../<feature>") instead of its internals. Escape hatch: "// ${IGNORE_TAG} [reason]" on the import line.`,
|
|
223
|
+
run(files) {
|
|
224
|
+
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
225
|
+
|
|
226
|
+
for (const sf of files) {
|
|
227
|
+
const file = sf.getFilePath();
|
|
228
|
+
if (EXCLUDE.test(file)) continue;
|
|
229
|
+
if (isAllowlisted(file).allowed) continue;
|
|
230
|
+
|
|
231
|
+
for (const v of findCrossFeatureViolations(sf)) {
|
|
232
|
+
violations.push({
|
|
233
|
+
file: path.relative(ROOT, file),
|
|
234
|
+
line: v.line,
|
|
235
|
+
message: `feature "${v.fileFeature}" → "${v.importedFeature}" (${v.importPath})`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { violations };
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
if (import.meta.main) runStandalone(guard);
|