@sansavision/aurora 0.1.0-alpha.20260212.4
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/README.md +4 -0
- package/package.json +17 -0
- package/src/ai-diagnostics.ts +156 -0
- package/src/ai.ts +574 -0
- package/src/analyze.ts +669 -0
- package/src/bin/aurora.ts +15 -0
- package/src/build.ts +431 -0
- package/src/bun-test-shims.d.ts +17 -0
- package/src/create-feature.ts +419 -0
- package/src/create-route.ts +581 -0
- package/src/create.ts +425 -0
- package/src/dev.ts +126 -0
- package/src/devtools.ts +1143 -0
- package/src/doctor.ts +611 -0
- package/src/explain.ts +855 -0
- package/src/help.ts +39 -0
- package/src/index.ts +34 -0
- package/src/init.ts +1011 -0
- package/src/inspect-cache.ts +464 -0
- package/src/lsp-inline-hints.ts +254 -0
- package/src/node-shims.d.ts +26 -0
- package/src/process.d.ts +11 -0
- package/src/query-profiler.ts +520 -0
- package/src/realtime-monitor.ts +389 -0
- package/src/registry.ts +303 -0
- package/src/run.ts +37 -0
- package/src/start.ts +56 -0
- package/src/test.ts +289 -0
- package/templates/basic/README.md +16 -0
- package/templates/basic/package.json +10 -0
- package/templates/basic/src/actions/createMessage.action.server.ts +22 -0
- package/templates/basic/src/lib/auth.server.ts +11 -0
- package/templates/basic/src/queries/listMessages.server.ts +17 -0
- package/templates/basic/src/routes/index.tsx +12 -0
- package/templates/blog/README.md +17 -0
- package/templates/blog/package.json +12 -0
- package/templates/blog/public/assets/og-default.svg +17 -0
- package/templates/blog/src/content/loadPosts.server.ts +22 -0
- package/templates/blog/src/content/posts/hello-world.md +11 -0
- package/templates/blog/src/content/posts/release-notes.md +9 -0
- package/templates/blog/src/routes/index.tsx +22 -0
- package/templates/blog/src/routes/posts/[slug].tsx +19 -0
- package/templates/blog/src/seo/meta.ts +19 -0
- package/templates/dashboard/README.md +18 -0
- package/templates/dashboard/package.json +10 -0
- package/templates/dashboard/src/actions/acknowledgeAlert.action.server.ts +6 -0
- package/templates/dashboard/src/queries/getDashboardMetrics.server.ts +30 -0
- package/templates/dashboard/src/realtime/useDashboardRealtime.client.ts +13 -0
- package/templates/dashboard/src/routes/index.tsx +19 -0
- package/templates/dashboard/src/widgets/DataGrid.client.ts +8 -0
- package/templates/dashboard/src/widgets/MetricChart.client.ts +8 -0
- package/templates/desktop/README.md +18 -0
- package/templates/desktop/package.json +11 -0
- package/templates/desktop/src/actions/saveDesktopPreference.action.server.ts +28 -0
- package/templates/desktop/src/desktop/secureStorage.client.ts +20 -0
- package/templates/desktop/src/desktop/tauriBridge.client.ts +14 -0
- package/templates/desktop/src/queries/getDesktopSyncStatus.server.ts +9 -0
- package/templates/desktop/src/routes/index.tsx +27 -0
- package/templates/desktop/src/sync/offlineSyncBoundary.server.ts +27 -0
- package/templates/feature-skeleton/README.md +13 -0
- package/templates/feature-skeleton/actions/createFeature.action.server.ts +19 -0
- package/templates/feature-skeleton/index.ts +8 -0
- package/templates/feature-skeleton/queries/listFeature.server.ts +15 -0
- package/templates/feature-skeleton/realtime/useFeatureRealtime.client.ts +16 -0
- package/templates/feature-skeleton/template.manifest.json +15 -0
- package/templates/feature-skeleton/ui/FeatureView.client.tsx +14 -0
- package/templates/mobile/README.md +17 -0
- package/templates/mobile/package.json +11 -0
- package/templates/mobile/src/mobile/auth/session-handoff.client.ts +69 -0
- package/templates/mobile/src/mobile/generated/mobile-api-sdk.ts +62 -0
- package/templates/mobile/src/mobile/transport/mobile-api-transport.client.ts +122 -0
- package/templates/mobile/src/routes/index.tsx +134 -0
- package/templates/monorepo/README.md +18 -0
- package/templates/monorepo/apps/web/package.json +9 -0
- package/templates/monorepo/apps/web/src/routes/index.tsx +1 -0
- package/templates/monorepo/package.json +13 -0
- package/templates/monorepo/packages/shared/README.md +3 -0
- package/templates/monorepo/packages/ui/README.md +3 -0
- package/templates/saas/README.md +17 -0
- package/templates/saas/package.json +10 -0
- package/templates/saas/src/admin/getDashboard.server.ts +18 -0
- package/templates/saas/src/auth/session.server.ts +13 -0
- package/templates/saas/src/billing/checkout.server.ts +11 -0
- package/templates/saas/src/email/sendWelcome.server.ts +8 -0
- package/templates/saas/src/realtime/notifications.server.ts +8 -0
- package/templates/saas/src/routes/index.tsx +20 -0
- package/test/ai.test.ts +94 -0
- package/test/analyze.test.ts +301 -0
- package/test/build.test.ts +135 -0
- package/test/create-feature.test.ts +145 -0
- package/test/create-route.test.ts +117 -0
- package/test/create.test.ts +222 -0
- package/test/dev.test.ts +52 -0
- package/test/devtools.test.ts +130 -0
- package/test/doctor.test.ts +129 -0
- package/test/explain.test.ts +232 -0
- package/test/feature-skeleton.test.ts +53 -0
- package/test/fixtures/analyze/cache-input.invalid.json +1 -0
- package/test/fixtures/analyze/cache-input.missing-keyhash.v1.json +10 -0
- package/test/fixtures/analyze/cache-input.unsupported-version.v2.json +10 -0
- package/test/fixtures/analyze/cache-input.v1.json +12 -0
- package/test/fixtures/analyze/compiler-manifest/manifest.json +11 -0
- package/test/fixtures/analyze/guardrails-input.unsupported-version.v2.json +4 -0
- package/test/fixtures/analyze/guardrails-input.v1.json +49 -0
- package/test/fixtures/analyze/query-input.invalid-cache-status.v1.json +11 -0
- package/test/fixtures/analyze/query-input.unsupported-version.v2.json +11 -0
- package/test/fixtures/analyze/query-input.v1.json +18 -0
- package/test/fixtures/analyze/realtime-input.missing-lag-p95.v1.json +10 -0
- package/test/fixtures/analyze/realtime-input.unsupported-version.v2.json +8 -0
- package/test/fixtures/analyze/realtime-input.v1.json +12 -0
- package/test/fixtures/cache-inspector/cache-input.v1.json +23 -0
- package/test/fixtures/cache-inspector/invalid.json +1 -0
- package/test/fixtures/cache-inspector/snapshot.v1.json +34 -0
- package/test/fixtures/cache-inspector/unsupported-version.v2.json +13 -0
- package/test/fixtures/devtools/healthy.v1.json +130 -0
- package/test/fixtures/devtools/invalid.json +1 -0
- package/test/fixtures/devtools/unsupported-version.v2.json +8 -0
- package/test/fixtures/devtools/warn.v1.json +114 -0
- package/test/fixtures/doctor/clean/src/page.tsx +3 -0
- package/test/fixtures/doctor/findings/src/accessibility.client.tsx +7 -0
- package/test/fixtures/doctor/findings/src/migration.config.ts +3 -0
- package/test/fixtures/doctor/findings/src/page.client.tsx +5 -0
- package/test/fixtures/doctor/findings/src/perf.server.ts +15 -0
- package/test/fixtures/doctor/findings/src/routes.js +3 -0
- package/test/fixtures/doctor/findings/src/security.server.ts +7 -0
- package/test/fixtures/doctor/findings/src/users.server.ts +3 -0
- package/test/fixtures/doctor/governance/src/features/analytics/OWNERS.ts +2 -0
- package/test/fixtures/doctor/governance/src/features/analytics/page.tsx +3 -0
- package/test/fixtures/doctor/governance/src/features/billing/page.tsx +3 -0
- package/test/fixtures/explain/invalid.json +1 -0
- package/test/fixtures/explain/module-report.unsupported-version.v2.json +6 -0
- package/test/fixtures/explain/module-report.v1.json +72 -0
- package/test/fixtures/query-profiler/healthy.v1.json +11 -0
- package/test/fixtures/query-profiler/invalid.json +1 -0
- package/test/fixtures/query-profiler/unsupported-version.v2.json +6 -0
- package/test/fixtures/query-profiler/warning.v1.json +10 -0
- package/test/fixtures/realtime-monitor/healthy.v1.json +8 -0
- package/test/fixtures/realtime-monitor/invalid.json +1 -0
- package/test/fixtures/realtime-monitor/unsupported-version.v2.json +8 -0
- package/test/fixtures/realtime-monitor/warning.v1.json +8 -0
- package/test/help-parity.test.ts +104 -0
- package/test/init.test.ts +164 -0
- package/test/inspect-cache.test.ts +112 -0
- package/test/lsp-inline-hints.test.ts +65 -0
- package/test/query-profiler.test.ts +123 -0
- package/test/realtime-monitor.test.ts +115 -0
- package/test/registry.test.ts +41 -0
- package/test/start.test.ts +23 -0
- package/test/test-command.test.ts +65 -0
- package/tsconfig.json +19 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { toAiReadableDiagnostic, type AiReadableDiagnostic } from "./ai-diagnostics";
|
|
5
|
+
import { type CommandContext, type CommandResult } from "./registry";
|
|
6
|
+
|
|
7
|
+
type DoctorFormat = "text" | "json";
|
|
8
|
+
type DoctorSeverity = "error" | "warn";
|
|
9
|
+
type DoctorCategory =
|
|
10
|
+
| "boundary"
|
|
11
|
+
| "migration"
|
|
12
|
+
| "security"
|
|
13
|
+
| "performance"
|
|
14
|
+
| "a11y"
|
|
15
|
+
| "governance";
|
|
16
|
+
type DoctorCode =
|
|
17
|
+
| "BOUNDARY_CLIENT_IMPORTS_SERVER"
|
|
18
|
+
| "DEPRECATED_ROUTE_API"
|
|
19
|
+
| "MIGRATION_MANUAL_ROUTES_ARRAY"
|
|
20
|
+
| "SECURITY_DYNAMIC_CODE_EXECUTION"
|
|
21
|
+
| "SECURITY_RAW_SQL_STRING"
|
|
22
|
+
| "PERF_UNBOUNDED_QUERY"
|
|
23
|
+
| "PERF_N_PLUS_ONE_LOOP_AWAIT"
|
|
24
|
+
| "A11Y_IMAGE_MISSING_ALT"
|
|
25
|
+
| "A11Y_CLICKABLE_NON_INTERACTIVE"
|
|
26
|
+
| "GOVERNANCE_MISSING_MODULE_OWNER"
|
|
27
|
+
| "GOVERNANCE_INVALID_OWNERS_EXPORT";
|
|
28
|
+
|
|
29
|
+
interface DoctorOptions {
|
|
30
|
+
format: DoctorFormat;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DoctorFinding {
|
|
34
|
+
code: DoctorCode;
|
|
35
|
+
category: DoctorCategory;
|
|
36
|
+
severity: DoctorSeverity;
|
|
37
|
+
file: string;
|
|
38
|
+
line: number;
|
|
39
|
+
message: string;
|
|
40
|
+
remediation: string;
|
|
41
|
+
ai: AiReadableDiagnostic;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface DoctorReport {
|
|
45
|
+
mode: "doctor";
|
|
46
|
+
projectRoot: string;
|
|
47
|
+
issues: DoctorFinding[];
|
|
48
|
+
errors: number;
|
|
49
|
+
warnings: number;
|
|
50
|
+
byCategory: Record<DoctorCategory, number>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SCANNABLE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
54
|
+
const OWNERS_HANDLE_PATTERN = /^@[A-Za-z0-9._/-]+$/;
|
|
55
|
+
|
|
56
|
+
function parseDoctorOptions(args: ReadonlyArray<string>): DoctorOptions | CommandResult {
|
|
57
|
+
const options: DoctorOptions = { format: "text" };
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
60
|
+
const arg = args[i];
|
|
61
|
+
if (arg === "--format") {
|
|
62
|
+
const value = args[i + 1];
|
|
63
|
+
if (!value) {
|
|
64
|
+
return {
|
|
65
|
+
exitCode: 2,
|
|
66
|
+
stderr: "aurora doctor: --format requires 'text' or 'json'",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (value !== "text" && value !== "json") {
|
|
71
|
+
return {
|
|
72
|
+
exitCode: 2,
|
|
73
|
+
stderr: `aurora doctor: invalid format '${value}'. Expected 'text' or 'json'`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
options.format = value;
|
|
78
|
+
i += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
exitCode: 2,
|
|
84
|
+
stderr: `aurora doctor: unknown option '${arg}'`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return options;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isScannableFile(path: string): boolean {
|
|
92
|
+
for (const extension of SCANNABLE_EXTENSIONS) {
|
|
93
|
+
if (path.endsWith(extension)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function walkProjectFiles(root: string, directory = root): string[] {
|
|
102
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
103
|
+
const results: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".aurora") {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const absolute = resolve(directory, entry.name);
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
results.push(...walkProjectFiles(root, absolute));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!entry.isFile()) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isScannableFile(absolute)) {
|
|
121
|
+
results.push(absolute);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function findLineNumber(source: string, index: number): number {
|
|
129
|
+
if (index <= 0) {
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return source.slice(0, index).split("\n").length;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function createFinding(input: {
|
|
137
|
+
code: DoctorCode;
|
|
138
|
+
category: DoctorCategory;
|
|
139
|
+
severity: DoctorSeverity;
|
|
140
|
+
file: string;
|
|
141
|
+
line: number;
|
|
142
|
+
message: string;
|
|
143
|
+
remediation: string;
|
|
144
|
+
}): DoctorFinding {
|
|
145
|
+
return {
|
|
146
|
+
code: input.code,
|
|
147
|
+
category: input.category,
|
|
148
|
+
severity: input.severity,
|
|
149
|
+
file: input.file,
|
|
150
|
+
line: input.line,
|
|
151
|
+
message: input.message,
|
|
152
|
+
remediation: input.remediation,
|
|
153
|
+
ai: toAiReadableDiagnostic({
|
|
154
|
+
code: input.code,
|
|
155
|
+
severity: input.severity,
|
|
156
|
+
message: input.message,
|
|
157
|
+
remediation: input.remediation,
|
|
158
|
+
category: input.category,
|
|
159
|
+
file: input.file,
|
|
160
|
+
line: input.line,
|
|
161
|
+
}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scanBoundaryViolations(filePath: string, source: string, root: string): DoctorFinding[] {
|
|
166
|
+
const relativePath = relative(root, filePath);
|
|
167
|
+
const isClientFile =
|
|
168
|
+
relativePath.includes(".client.") ||
|
|
169
|
+
relativePath.endsWith(".client.ts") ||
|
|
170
|
+
relativePath.endsWith(".client.tsx");
|
|
171
|
+
|
|
172
|
+
if (!isClientFile) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const findings: DoctorFinding[] = [];
|
|
177
|
+
const regex = /from\s+["'][^"']*\.server(?:\.[^"']*)?["']/g;
|
|
178
|
+
let match = regex.exec(source);
|
|
179
|
+
|
|
180
|
+
while (match) {
|
|
181
|
+
findings.push(
|
|
182
|
+
createFinding({
|
|
183
|
+
code: "BOUNDARY_CLIENT_IMPORTS_SERVER",
|
|
184
|
+
category: "boundary",
|
|
185
|
+
severity: "error",
|
|
186
|
+
file: relativePath,
|
|
187
|
+
line: findLineNumber(source, match.index),
|
|
188
|
+
message: "client module imports a server-only module",
|
|
189
|
+
remediation:
|
|
190
|
+
"Move server calls behind action/query boundaries and import only client-safe modules.",
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
match = regex.exec(source);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return findings;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function scanMigrationRisks(filePath: string, source: string, root: string): DoctorFinding[] {
|
|
200
|
+
const findings: DoctorFinding[] = [];
|
|
201
|
+
const relativePath = relative(root, filePath);
|
|
202
|
+
|
|
203
|
+
const deprecatedRouteRegex = /\broute\s*\(\s*["'`]\//g;
|
|
204
|
+
let routeMatch = deprecatedRouteRegex.exec(source);
|
|
205
|
+
while (routeMatch) {
|
|
206
|
+
findings.push(
|
|
207
|
+
createFinding({
|
|
208
|
+
code: "DEPRECATED_ROUTE_API",
|
|
209
|
+
category: "migration",
|
|
210
|
+
severity: "warn",
|
|
211
|
+
file: relativePath,
|
|
212
|
+
line: findLineNumber(source, routeMatch.index),
|
|
213
|
+
message: "manual route() API is deprecated; use file-based routing conventions",
|
|
214
|
+
remediation:
|
|
215
|
+
"Replace route() registrations with file-based route modules and route metadata exports.",
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
routeMatch = deprecatedRouteRegex.exec(source);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const manualRoutesRegex = /\broutes\s*:\s*\[/g;
|
|
222
|
+
let manualRouteMatch = manualRoutesRegex.exec(source);
|
|
223
|
+
while (manualRouteMatch) {
|
|
224
|
+
findings.push(
|
|
225
|
+
createFinding({
|
|
226
|
+
code: "MIGRATION_MANUAL_ROUTES_ARRAY",
|
|
227
|
+
category: "migration",
|
|
228
|
+
severity: "warn",
|
|
229
|
+
file: relativePath,
|
|
230
|
+
line: findLineNumber(source, manualRouteMatch.index),
|
|
231
|
+
message: "manual routes array detected",
|
|
232
|
+
remediation:
|
|
233
|
+
"Migrate to file-based routes (`page.tsx`, `<route>.client.tsx`, `<route>.server.ts`) and remove manual route arrays.",
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
manualRouteMatch = manualRoutesRegex.exec(source);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return findings;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function scanSecurityRisks(filePath: string, source: string, root: string): DoctorFinding[] {
|
|
243
|
+
const findings: DoctorFinding[] = [];
|
|
244
|
+
const relativePath = relative(root, filePath);
|
|
245
|
+
|
|
246
|
+
const dynamicCodeRegex = /\beval\s*\(|\bnew\s+Function\s*\(/g;
|
|
247
|
+
let dynamicCodeMatch = dynamicCodeRegex.exec(source);
|
|
248
|
+
while (dynamicCodeMatch) {
|
|
249
|
+
findings.push(
|
|
250
|
+
createFinding({
|
|
251
|
+
code: "SECURITY_DYNAMIC_CODE_EXECUTION",
|
|
252
|
+
category: "security",
|
|
253
|
+
severity: "error",
|
|
254
|
+
file: relativePath,
|
|
255
|
+
line: findLineNumber(source, dynamicCodeMatch.index),
|
|
256
|
+
message: "dynamic code execution detected (eval/new Function)",
|
|
257
|
+
remediation:
|
|
258
|
+
"Remove dynamic code execution and use explicit parsing/dispatch logic with allow-listed operations.",
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
dynamicCodeMatch = dynamicCodeRegex.exec(source);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const rawSqlRegex = /\b(?:query|execute)\s*\(\s*["'`]\s*(?:select|insert|update|delete)\b/gi;
|
|
265
|
+
let rawSqlMatch = rawSqlRegex.exec(source);
|
|
266
|
+
while (rawSqlMatch) {
|
|
267
|
+
findings.push(
|
|
268
|
+
createFinding({
|
|
269
|
+
code: "SECURITY_RAW_SQL_STRING",
|
|
270
|
+
category: "security",
|
|
271
|
+
severity: "warn",
|
|
272
|
+
file: relativePath,
|
|
273
|
+
line: findLineNumber(source, rawSqlMatch.index),
|
|
274
|
+
message: "raw SQL string execution detected",
|
|
275
|
+
remediation:
|
|
276
|
+
"Use parameterized queries or tagged SQL templates to avoid injection and improve auditability.",
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
rawSqlMatch = rawSqlRegex.exec(source);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return findings;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function scanPerformanceRisks(filePath: string, source: string, root: string): DoctorFinding[] {
|
|
286
|
+
const findings: DoctorFinding[] = [];
|
|
287
|
+
const relativePath = relative(root, filePath);
|
|
288
|
+
|
|
289
|
+
const unboundedQueryRegex = /\.(?:findMany|list|scan)\s*\(\s*\)/g;
|
|
290
|
+
let unboundedMatch = unboundedQueryRegex.exec(source);
|
|
291
|
+
while (unboundedMatch) {
|
|
292
|
+
findings.push(
|
|
293
|
+
createFinding({
|
|
294
|
+
code: "PERF_UNBOUNDED_QUERY",
|
|
295
|
+
category: "performance",
|
|
296
|
+
severity: "warn",
|
|
297
|
+
file: relativePath,
|
|
298
|
+
line: findLineNumber(source, unboundedMatch.index),
|
|
299
|
+
message: "potential unbounded query detected",
|
|
300
|
+
remediation:
|
|
301
|
+
"Add pagination/limit parameters and stable ordering to bound query result size.",
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
unboundedMatch = unboundedQueryRegex.exec(source);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const nPlusOneRegex = /for\s*\([^)]*\)\s*\{[\s\S]{0,220}?await\s+[A-Za-z0-9_$.]+\s*\(/g;
|
|
308
|
+
let nPlusOneMatch = nPlusOneRegex.exec(source);
|
|
309
|
+
while (nPlusOneMatch) {
|
|
310
|
+
findings.push(
|
|
311
|
+
createFinding({
|
|
312
|
+
code: "PERF_N_PLUS_ONE_LOOP_AWAIT",
|
|
313
|
+
category: "performance",
|
|
314
|
+
severity: "warn",
|
|
315
|
+
file: relativePath,
|
|
316
|
+
line: findLineNumber(source, nPlusOneMatch.index),
|
|
317
|
+
message: "serial await inside loop may cause N+1 latency",
|
|
318
|
+
remediation:
|
|
319
|
+
"Batch calls with Promise.all or prefetch in bulk outside the loop.",
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
nPlusOneMatch = nPlusOneRegex.exec(source);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return findings;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function scanA11yRisks(filePath: string, source: string, root: string): DoctorFinding[] {
|
|
329
|
+
const findings: DoctorFinding[] = [];
|
|
330
|
+
const relativePath = relative(root, filePath);
|
|
331
|
+
|
|
332
|
+
const imgMissingAltRegex = /<img\b(?![^>]*\balt=)[^>]*>/gi;
|
|
333
|
+
let imgMatch = imgMissingAltRegex.exec(source);
|
|
334
|
+
while (imgMatch) {
|
|
335
|
+
findings.push(
|
|
336
|
+
createFinding({
|
|
337
|
+
code: "A11Y_IMAGE_MISSING_ALT",
|
|
338
|
+
category: "a11y",
|
|
339
|
+
severity: "warn",
|
|
340
|
+
file: relativePath,
|
|
341
|
+
line: findLineNumber(source, imgMatch.index),
|
|
342
|
+
message: "image tag missing alt text",
|
|
343
|
+
remediation:
|
|
344
|
+
"Add an `alt` attribute with meaningful text, or `alt=\"\"` for decorative images.",
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
imgMatch = imgMissingAltRegex.exec(source);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const clickableDivRegex = /<div\b[^>]*\bonClick=/gi;
|
|
351
|
+
let clickableDivMatch = clickableDivRegex.exec(source);
|
|
352
|
+
while (clickableDivMatch) {
|
|
353
|
+
findings.push(
|
|
354
|
+
createFinding({
|
|
355
|
+
code: "A11Y_CLICKABLE_NON_INTERACTIVE",
|
|
356
|
+
category: "a11y",
|
|
357
|
+
severity: "warn",
|
|
358
|
+
file: relativePath,
|
|
359
|
+
line: findLineNumber(source, clickableDivMatch.index),
|
|
360
|
+
message: "click handler attached to non-interactive element",
|
|
361
|
+
remediation:
|
|
362
|
+
"Prefer `<button>`/`<a>` or add keyboard handlers and semantic role attributes.",
|
|
363
|
+
}),
|
|
364
|
+
);
|
|
365
|
+
clickableDivMatch = clickableDivRegex.exec(source);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return findings;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function scanGovernanceOwnership(root: string, files: readonly string[]): DoctorFinding[] {
|
|
372
|
+
const featureRoots = new Set<string>();
|
|
373
|
+
|
|
374
|
+
for (const filePath of files) {
|
|
375
|
+
const relativePath = relative(root, filePath).replaceAll("\\", "/");
|
|
376
|
+
const featureMatch = /^src\/features\/([^/]+)\//.exec(relativePath);
|
|
377
|
+
if (!featureMatch) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
featureRoots.add(`src/features/${featureMatch[1]}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const findings: DoctorFinding[] = [];
|
|
385
|
+
|
|
386
|
+
for (const featureRoot of [...featureRoots].sort()) {
|
|
387
|
+
const ownersFile = resolve(root, featureRoot, "OWNERS.ts");
|
|
388
|
+
let ownersSource = "";
|
|
389
|
+
|
|
390
|
+
try {
|
|
391
|
+
ownersSource = readFileSync(ownersFile, "utf8");
|
|
392
|
+
} catch {
|
|
393
|
+
findings.push(
|
|
394
|
+
createFinding({
|
|
395
|
+
code: "GOVERNANCE_MISSING_MODULE_OWNER",
|
|
396
|
+
category: "governance",
|
|
397
|
+
severity: "warn",
|
|
398
|
+
file: `${featureRoot}/OWNERS.ts`,
|
|
399
|
+
line: 1,
|
|
400
|
+
message: `feature module "${featureRoot}" is missing OWNERS.ts ownership metadata`,
|
|
401
|
+
remediation:
|
|
402
|
+
"Create OWNERS.ts and export at least one handle: `export const owners = [\"@team-name\"]`.",
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const ownersMatch = /export\s+const\s+owners\s*=\s*\[([\s\S]*?)\]/m.exec(ownersSource);
|
|
409
|
+
if (!ownersMatch) {
|
|
410
|
+
findings.push(
|
|
411
|
+
createFinding({
|
|
412
|
+
code: "GOVERNANCE_INVALID_OWNERS_EXPORT",
|
|
413
|
+
category: "governance",
|
|
414
|
+
severity: "warn",
|
|
415
|
+
file: `${featureRoot}/OWNERS.ts`,
|
|
416
|
+
line: 1,
|
|
417
|
+
message: "OWNERS.ts must export `owners` as an array of @handles",
|
|
418
|
+
remediation:
|
|
419
|
+
"Add `export const owners = [\"@team-name\"]` (and optional `reviewers`) to OWNERS.ts.",
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const ownersLine = findLineNumber(ownersSource, ownersMatch.index);
|
|
426
|
+
const handles = [...ownersMatch[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((match) =>
|
|
427
|
+
match[1].trim(),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (handles.length === 0) {
|
|
431
|
+
findings.push(
|
|
432
|
+
createFinding({
|
|
433
|
+
code: "GOVERNANCE_INVALID_OWNERS_EXPORT",
|
|
434
|
+
category: "governance",
|
|
435
|
+
severity: "warn",
|
|
436
|
+
file: `${featureRoot}/OWNERS.ts`,
|
|
437
|
+
line: ownersLine,
|
|
438
|
+
message: "OWNERS.ts owners export must contain at least one handle",
|
|
439
|
+
remediation: "Use at least one owner handle: `export const owners = [\"@team-name\"]`.",
|
|
440
|
+
}),
|
|
441
|
+
);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const invalidHandles = handles.filter((handle) => !OWNERS_HANDLE_PATTERN.test(handle));
|
|
446
|
+
if (invalidHandles.length > 0) {
|
|
447
|
+
findings.push(
|
|
448
|
+
createFinding({
|
|
449
|
+
code: "GOVERNANCE_INVALID_OWNERS_EXPORT",
|
|
450
|
+
category: "governance",
|
|
451
|
+
severity: "warn",
|
|
452
|
+
file: `${featureRoot}/OWNERS.ts`,
|
|
453
|
+
line: ownersLine,
|
|
454
|
+
message:
|
|
455
|
+
"OWNERS.ts owners export contains invalid handles: " +
|
|
456
|
+
invalidHandles.map((handle) => `"${handle}"`).join(", "),
|
|
457
|
+
remediation:
|
|
458
|
+
"Use @prefixed handles with letters, numbers, '.', '-', '_', or '/'.",
|
|
459
|
+
}),
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return findings;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function summarizeByCategory(findings: readonly DoctorFinding[]): Record<DoctorCategory, number> {
|
|
468
|
+
const counts: Record<DoctorCategory, number> = {
|
|
469
|
+
boundary: 0,
|
|
470
|
+
migration: 0,
|
|
471
|
+
security: 0,
|
|
472
|
+
performance: 0,
|
|
473
|
+
a11y: 0,
|
|
474
|
+
governance: 0,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
for (const finding of findings) {
|
|
478
|
+
counts[finding.category] += 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return counts;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function runDoctorScans(context: CommandContext): DoctorReport | CommandResult {
|
|
485
|
+
const root = context.cwd;
|
|
486
|
+
let files: string[];
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
const stats = statSync(root);
|
|
490
|
+
if (!stats.isDirectory()) {
|
|
491
|
+
return {
|
|
492
|
+
exitCode: 1,
|
|
493
|
+
stderr: `aurora doctor: project root is not a directory: ${root}`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
files = walkProjectFiles(root);
|
|
497
|
+
} catch {
|
|
498
|
+
return {
|
|
499
|
+
exitCode: 1,
|
|
500
|
+
stderr: `aurora doctor: unable to access project root: ${root}`,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const findings: DoctorFinding[] = [];
|
|
505
|
+
|
|
506
|
+
for (const filePath of files) {
|
|
507
|
+
let source = "";
|
|
508
|
+
try {
|
|
509
|
+
source = readFileSync(filePath, "utf8");
|
|
510
|
+
} catch {
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
findings.push(...scanBoundaryViolations(filePath, source, root));
|
|
515
|
+
findings.push(...scanMigrationRisks(filePath, source, root));
|
|
516
|
+
findings.push(...scanSecurityRisks(filePath, source, root));
|
|
517
|
+
findings.push(...scanPerformanceRisks(filePath, source, root));
|
|
518
|
+
findings.push(...scanA11yRisks(filePath, source, root));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
findings.push(...scanGovernanceOwnership(root, files));
|
|
522
|
+
|
|
523
|
+
findings.sort((left, right) => {
|
|
524
|
+
const bySeverity = left.severity.localeCompare(right.severity);
|
|
525
|
+
if (bySeverity !== 0) {
|
|
526
|
+
return bySeverity;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const byFile = left.file.localeCompare(right.file);
|
|
530
|
+
if (byFile !== 0) {
|
|
531
|
+
return byFile;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const byLine = left.line - right.line;
|
|
535
|
+
if (byLine !== 0) {
|
|
536
|
+
return byLine;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return left.code.localeCompare(right.code);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const errors = findings.filter((finding) => finding.severity === "error").length;
|
|
543
|
+
const warnings = findings.filter((finding) => finding.severity === "warn").length;
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
mode: "doctor",
|
|
547
|
+
projectRoot: root,
|
|
548
|
+
issues: findings,
|
|
549
|
+
errors,
|
|
550
|
+
warnings,
|
|
551
|
+
byCategory: summarizeByCategory(findings),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function renderDoctorTextReport(report: DoctorReport): string {
|
|
556
|
+
const lines = [
|
|
557
|
+
"aurora doctor report",
|
|
558
|
+
`project_root: ${report.projectRoot}`,
|
|
559
|
+
`issues: ${report.issues.length}`,
|
|
560
|
+
`errors: ${report.errors}`,
|
|
561
|
+
`warnings: ${report.warnings}`,
|
|
562
|
+
"categories:",
|
|
563
|
+
`- boundary: ${report.byCategory.boundary}`,
|
|
564
|
+
`- migration: ${report.byCategory.migration}`,
|
|
565
|
+
`- security: ${report.byCategory.security}`,
|
|
566
|
+
`- performance: ${report.byCategory.performance}`,
|
|
567
|
+
`- a11y: ${report.byCategory.a11y}`,
|
|
568
|
+
`- governance: ${report.byCategory.governance}`,
|
|
569
|
+
];
|
|
570
|
+
|
|
571
|
+
if (report.issues.length > 0) {
|
|
572
|
+
lines.push("findings:");
|
|
573
|
+
for (const issue of report.issues) {
|
|
574
|
+
lines.push(
|
|
575
|
+
`- [${issue.severity}] [${issue.category}] ${issue.code} ${issue.file}:${issue.line} ${issue.message}`,
|
|
576
|
+
);
|
|
577
|
+
lines.push(` fix: ${issue.remediation}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return lines.join("\n");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function runDoctorCommand(
|
|
585
|
+
args: ReadonlyArray<string>,
|
|
586
|
+
context: CommandContext,
|
|
587
|
+
): CommandResult {
|
|
588
|
+
const options = parseDoctorOptions(args);
|
|
589
|
+
if ("exitCode" in options) {
|
|
590
|
+
return options;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const report = runDoctorScans(context);
|
|
594
|
+
if ("exitCode" in report) {
|
|
595
|
+
return report;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const exitCode = report.errors > 0 ? 1 : 0;
|
|
599
|
+
|
|
600
|
+
if (options.format === "json") {
|
|
601
|
+
return {
|
|
602
|
+
exitCode,
|
|
603
|
+
stdout: JSON.stringify(report, null, 2),
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
exitCode,
|
|
609
|
+
stdout: renderDoctorTextReport(report),
|
|
610
|
+
};
|
|
611
|
+
}
|