@askrjs/cli 0.0.9 → 0.0.11

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.
@@ -0,0 +1,1452 @@
1
+ import { t as writeFileChanges } from "./file-changes-BAFLhEHZ.js";
2
+ import { discoverWorkspaceProject } from "./discovery-DUDrZCIC.js";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { minimatch } from "minimatch";
6
+ import ts from "typescript";
7
+ //#region src/analyze/project.ts
8
+ const DEFAULT_ANALYZE_EXCLUDES = [
9
+ "**/node_modules/**",
10
+ "**/dist/**",
11
+ "**/build/**",
12
+ "**/coverage/**",
13
+ "**/.git/**",
14
+ "**/.next/**",
15
+ "**/.output/**",
16
+ "**/.turbo/**",
17
+ "**/generated/**",
18
+ "**/*.d.ts"
19
+ ];
20
+ const SOURCE_EXTENSION = /\.[cm]?[jt]sx?$/;
21
+ function asObject(value, message) {
22
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(message);
23
+ return value;
24
+ }
25
+ function readAnalyzeConfiguration(rootManifest) {
26
+ const askr = rootManifest.askr;
27
+ if (askr === void 0) return {
28
+ exclude: [...DEFAULT_ANALYZE_EXCLUDES],
29
+ rules: {}
30
+ };
31
+ const raw = asObject(askr, "Invalid askr configuration in the workspace root.").analyze;
32
+ if (raw === void 0) return {
33
+ exclude: [...DEFAULT_ANALYZE_EXCLUDES],
34
+ rules: {}
35
+ };
36
+ const analyze = asObject(raw, "Invalid askr.analyze configuration; expected an object.");
37
+ const exclude = analyze.exclude ?? [];
38
+ const rules = analyze.rules ?? {};
39
+ if (!Array.isArray(exclude) || exclude.some((entry) => typeof entry !== "string" || !entry)) throw new Error("Invalid askr.analyze.exclude; expected an array of non-empty patterns.");
40
+ const ruleObject = asObject(rules, "Invalid askr.analyze.rules; expected rule-to-severity entries.");
41
+ const allowed = /* @__PURE__ */ new Set([
42
+ "off",
43
+ "info",
44
+ "warning",
45
+ "error"
46
+ ]);
47
+ if (Object.entries(ruleObject).some(([id, severity]) => !id || typeof severity !== "string" || !allowed.has(severity))) throw new Error("Invalid askr.analyze.rules; severities must be off, info, warning, or error.");
48
+ return {
49
+ exclude: [...DEFAULT_ANALYZE_EXCLUDES, ...exclude],
50
+ rules: ruleObject
51
+ };
52
+ }
53
+ function normalizeRelative(root, filePath) {
54
+ return path.relative(root, filePath).split(path.sep).join("/");
55
+ }
56
+ function isExcluded(root, filePath, patterns) {
57
+ const relative = normalizeRelative(root, filePath);
58
+ return patterns.some((pattern) => minimatch(relative, pattern, {
59
+ dot: true,
60
+ nocase: process.platform === "win32",
61
+ windowsPathsNoEscape: true
62
+ }));
63
+ }
64
+ async function discoverSourceFiles(directory, root, exclusions) {
65
+ const files = [];
66
+ const visit = async (current) => {
67
+ const entries = await fs.readdir(current, { withFileTypes: true });
68
+ entries.sort((left, right) => left.name.localeCompare(right.name));
69
+ for (const entry of entries) {
70
+ const child = path.join(current, entry.name);
71
+ if (isExcluded(root, child, exclusions)) continue;
72
+ if (entry.isDirectory()) await visit(child);
73
+ else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) files.push(child);
74
+ }
75
+ };
76
+ await visit(directory);
77
+ return files;
78
+ }
79
+ function formatConfigDiagnostic(diagnostic) {
80
+ return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
81
+ }
82
+ async function compilerInputs(workspace, configuration) {
83
+ const tsconfig = path.join(workspace.directory, "tsconfig.json");
84
+ const hasConfig = (await fs.stat(tsconfig).catch(() => null))?.isFile() ?? false;
85
+ let options = {
86
+ allowJs: true,
87
+ checkJs: false,
88
+ jsx: ts.JsxEmit.ReactJSX,
89
+ jsxImportSource: "@askrjs/askr",
90
+ module: ts.ModuleKind.ESNext,
91
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
92
+ noLib: true,
93
+ noEmit: true,
94
+ skipLibCheck: true,
95
+ target: ts.ScriptTarget.ES2022,
96
+ types: []
97
+ };
98
+ let configuredFiles = [];
99
+ if (hasConfig) {
100
+ const loaded = ts.readConfigFile(tsconfig, ts.sys.readFile);
101
+ if (loaded.error) throw new Error(`${tsconfig}: ${formatConfigDiagnostic(loaded.error)}`);
102
+ const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, workspace.directory, {
103
+ allowJs: true,
104
+ noEmit: true,
105
+ noLib: true,
106
+ skipLibCheck: true,
107
+ types: []
108
+ }, tsconfig);
109
+ const error = parsed.errors.find((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
110
+ if (error) throw new Error(`${tsconfig}: ${formatConfigDiagnostic(error)}`);
111
+ options = parsed.options;
112
+ configuredFiles = parsed.fileNames;
113
+ }
114
+ const discovered = await discoverSourceFiles(workspace.directory, workspace.directory, configuration.exclude);
115
+ return {
116
+ rootNames: [.../* @__PURE__ */ new Set([...configuredFiles, ...discovered])].filter((filePath) => !isExcluded(workspace.directory, filePath, configuration.exclude)).sort((left, right) => left.localeCompare(right)),
117
+ options,
118
+ tsconfig: hasConfig ? tsconfig : null
119
+ };
120
+ }
121
+ async function createWorkspaceAnalysisContext(root, workspace, configuration) {
122
+ const inputs = await compilerInputs(workspace, configuration);
123
+ const compilerHost = ts.createCompilerHost(inputs.options, true);
124
+ const moduleResolutionCache = ts.createModuleResolutionCache(workspace.directory, (fileName) => compilerHost.getCanonicalFileName(fileName), inputs.options);
125
+ compilerHost.resolveModuleNameLiterals = (moduleLiterals, containingFile, redirectedReference, options) => moduleLiterals.map((moduleLiteral) => {
126
+ const resolution = ts.resolveModuleName(moduleLiteral.text, containingFile, options, compilerHost, moduleResolutionCache, redirectedReference);
127
+ const resolved = resolution.resolvedModule;
128
+ if (!resolved) return resolution;
129
+ const realPath = ts.sys.realpath?.(resolved.resolvedFileName) ?? resolved.resolvedFileName;
130
+ const relativeToProject = path.relative(root, realPath);
131
+ return relativeToProject !== ".." && !relativeToProject.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeToProject) && !relativeToProject.split(path.sep).includes("node_modules") ? resolution : {
132
+ ...resolution,
133
+ resolvedModule: void 0
134
+ };
135
+ });
136
+ const program = ts.createProgram({
137
+ rootNames: inputs.rootNames,
138
+ options: inputs.options,
139
+ host: compilerHost
140
+ });
141
+ const sourceFileSet = new Set(inputs.rootNames.map((filePath) => path.resolve(filePath)));
142
+ const sourceFiles = program.getSourceFiles().filter((sourceFile) => sourceFileSet.has(path.resolve(sourceFile.fileName))).sort((left, right) => left.fileName.localeCompare(right.fileName));
143
+ return {
144
+ context: {
145
+ root,
146
+ workspace,
147
+ program,
148
+ checker: program.getTypeChecker(),
149
+ sourceFiles,
150
+ configuration
151
+ },
152
+ tsconfig: inputs.tsconfig
153
+ };
154
+ }
155
+ function workspaceRelativeFile(context, filePath) {
156
+ return normalizeRelative(context.workspace.directory, filePath) || path.basename(filePath);
157
+ }
158
+ //#endregion
159
+ //#region src/analyze/catalog.ts
160
+ const ASKR_CONCEPTS = {
161
+ reactive: [
162
+ "state",
163
+ "derive",
164
+ "selector"
165
+ ],
166
+ lifecycle: [
167
+ "resource",
168
+ "task",
169
+ "timer",
170
+ "stream",
171
+ "on"
172
+ ],
173
+ data: [
174
+ "createQuery",
175
+ "createMutation",
176
+ "defineQuery",
177
+ "serveQuery",
178
+ "defineServerQueries",
179
+ "prefetchQuery",
180
+ "invalidate",
181
+ "invalidateOnInterval",
182
+ "queryScope"
183
+ ],
184
+ control: [
185
+ "For",
186
+ "Show",
187
+ "Case",
188
+ "Match"
189
+ ],
190
+ routing: [
191
+ "route",
192
+ "page",
193
+ "index",
194
+ "group",
195
+ "fallback",
196
+ "lazy",
197
+ "createRouteRegistry"
198
+ ],
199
+ boot: [
200
+ "createSPA",
201
+ "hydrateSPA",
202
+ "createIsland",
203
+ "createIslands"
204
+ ],
205
+ actions: [
206
+ "defineAction",
207
+ "ActionForm",
208
+ "action"
209
+ ],
210
+ rendering: [
211
+ "renderToString",
212
+ "renderToStream",
213
+ "resolveRequest",
214
+ "createStaticGen"
215
+ ],
216
+ authorization: [
217
+ "allow",
218
+ "redirect",
219
+ "deny",
220
+ "unauthorized",
221
+ "forbidden",
222
+ "notFound",
223
+ "currentAuth"
224
+ ],
225
+ composition: [
226
+ "defineScope",
227
+ "readScope",
228
+ "createRef",
229
+ "Portal"
230
+ ]
231
+ };
232
+ const RENDER_SCOPED_CONCEPTS = /* @__PURE__ */ new Set([
233
+ ...ASKR_CONCEPTS.reactive,
234
+ ...ASKR_CONCEPTS.lifecycle,
235
+ "action"
236
+ ]);
237
+ const POSITIONAL_DATA_CONCEPTS = /* @__PURE__ */ new Set(["createQuery", "createMutation"]);
238
+ const ASKR_MODULE_PATTERN = /^@askrjs\/askr(?:\/|$)/;
239
+ //#endregion
240
+ //#region src/analyze/rules.ts
241
+ const SOURCE_BINDING_CACHE = /* @__PURE__ */ new WeakMap();
242
+ const SOURCE_FACT_CACHE = /* @__PURE__ */ new WeakMap();
243
+ function sourceBindings(sourceFile) {
244
+ const cached = SOURCE_BINDING_CACHE.get(sourceFile);
245
+ if (cached) return cached;
246
+ const named = /* @__PURE__ */ new Map();
247
+ const namespaces = /* @__PURE__ */ new Set();
248
+ for (const statement of sourceFile.statements) {
249
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !ASKR_MODULE_PATTERN.test(statement.moduleSpecifier.text)) continue;
250
+ const bindings = statement.importClause?.namedBindings;
251
+ if (bindings && ts.isNamedImports(bindings)) for (const element of bindings.elements) named.set(element.name.text, {
252
+ imported: element.propertyName?.text ?? element.name.text,
253
+ module: statement.moduleSpecifier.text
254
+ });
255
+ else if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
256
+ }
257
+ const bindings = {
258
+ named,
259
+ namespaces
260
+ };
261
+ SOURCE_BINDING_CACHE.set(sourceFile, bindings);
262
+ return bindings;
263
+ }
264
+ function canonicalCallName(expression, bindings) {
265
+ if (ts.isIdentifier(expression)) return bindings.named.get(expression.text)?.imported ?? null;
266
+ if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && bindings.namespaces.has(expression.expression.text)) return expression.name.text;
267
+ return null;
268
+ }
269
+ function canonicalJsxName(name, bindings) {
270
+ if (ts.isIdentifier(name)) return bindings.named.get(name.text)?.imported ?? null;
271
+ if (ts.isPropertyAccessExpression(name) && ts.isIdentifier(name.expression) && bindings.namespaces.has(name.expression.text)) return name.name.text;
272
+ return null;
273
+ }
274
+ function sourceFacts(sourceFile) {
275
+ const cached = SOURCE_FACT_CACHE.get(sourceFile);
276
+ if (cached) return cached;
277
+ const bindings = sourceBindings(sourceFile);
278
+ const calls = [];
279
+ const allCalls = [];
280
+ const jsx = [];
281
+ const constructions = [];
282
+ const walk = (node) => {
283
+ if (ts.isCallExpression(node)) {
284
+ allCalls.push(node);
285
+ const name = canonicalCallName(node.expression, bindings);
286
+ if (name) calls.push({
287
+ node,
288
+ name
289
+ });
290
+ } else if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
291
+ const name = canonicalJsxName(node.tagName, bindings);
292
+ if (name) jsx.push({
293
+ node,
294
+ name
295
+ });
296
+ } else if (ts.isNewExpression(node)) constructions.push(node);
297
+ ts.forEachChild(node, walk);
298
+ };
299
+ walk(sourceFile);
300
+ const facts = {
301
+ bindings,
302
+ calls,
303
+ allCalls,
304
+ jsx,
305
+ constructions
306
+ };
307
+ SOURCE_FACT_CACHE.set(sourceFile, facts);
308
+ return facts;
309
+ }
310
+ function location(context, node) {
311
+ const sourceFile = node.getSourceFile();
312
+ const point = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
313
+ return {
314
+ workspace: context.workspace.name,
315
+ file: workspaceRelativeFile(context, sourceFile.fileName),
316
+ line: point.line + 1,
317
+ column: point.character + 1
318
+ };
319
+ }
320
+ function diagnostic(context, node, rule, message, remediation, fix) {
321
+ return {
322
+ ruleId: rule.id,
323
+ category: rule.category,
324
+ severity: rule.severity,
325
+ message,
326
+ ...location(context, node),
327
+ ...remediation ? { remediation } : {},
328
+ ...fix ? { fix } : {}
329
+ };
330
+ }
331
+ function visit(sourceFile, callback) {
332
+ const walk = (node) => {
333
+ callback(node);
334
+ ts.forEachChild(node, walk);
335
+ };
336
+ walk(sourceFile);
337
+ }
338
+ function containingFunction(node) {
339
+ for (let current = node.parent; current; current = current.parent) if (ts.isFunctionLike(current)) return current;
340
+ return null;
341
+ }
342
+ function isControlFlowAncestor(node, boundary) {
343
+ for (let current = node.parent; current && current !== boundary; current = current.parent) {
344
+ if (ts.isIfStatement(current) || ts.isConditionalExpression(current) || ts.isSwitchStatement(current) || ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current) || ts.isTryStatement(current)) return true;
345
+ if (ts.isBinaryExpression(current) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken].includes(current.operatorToken.kind)) return true;
346
+ }
347
+ return false;
348
+ }
349
+ function functionName(node) {
350
+ if ("name" in node && node.name && ts.isIdentifier(node.name)) return node.name.text;
351
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
352
+ const parent = node.parent;
353
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text;
354
+ }
355
+ return null;
356
+ }
357
+ const stableRenderRule = {
358
+ id: "askr/stable-render-call",
359
+ category: "correctness",
360
+ severity: "error",
361
+ description: "Render-scoped Askr primitives must have stable top-level call order.",
362
+ analyze(context) {
363
+ const diagnostics = [];
364
+ for (const sourceFile of context.sourceFiles) {
365
+ const bindings = sourceBindings(sourceFile);
366
+ visit(sourceFile, (node) => {
367
+ if (!ts.isCallExpression(node)) return;
368
+ const name = canonicalCallName(node.expression, bindings);
369
+ if (!name || !RENDER_SCOPED_CONCEPTS.has(name) && !POSITIONAL_DATA_CONCEPTS.has(name)) return;
370
+ const owner = containingFunction(node);
371
+ if (!owner) {
372
+ if (POSITIONAL_DATA_CONCEPTS.has(name)) return;
373
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() is render-scoped and cannot be called at module scope.`, `Move ${name}() to the top level of an Askr component.`));
374
+ return;
375
+ }
376
+ if (isControlFlowAncestor(node, owner) && (!POSITIONAL_DATA_CONCEPTS.has(name) || /^[A-Z]/.test(functionName(owner) ?? "") || containsJsx(owner))) diagnostics.push(diagnostic(context, node.expression, this, `${name}() is called conditionally, so its render position is unstable.`, `Call ${name}() unconditionally at the top level and branch on its result.`));
377
+ });
378
+ }
379
+ return diagnostics;
380
+ }
381
+ };
382
+ function collectStateBindings(sourceFile, bindings) {
383
+ const getters = /* @__PURE__ */ new Set();
384
+ const setters = /* @__PURE__ */ new Set();
385
+ const owners = /* @__PURE__ */ new Map();
386
+ visit(sourceFile, (node) => {
387
+ if (!ts.isVariableDeclaration(node) || !node.initializer || !ts.isCallExpression(node.initializer) || canonicalCallName(node.initializer.expression, bindings) !== "state") return;
388
+ const owner = containingFunction(node);
389
+ if (ts.isIdentifier(node.name)) {
390
+ getters.add(node.name.text);
391
+ owners.set(node.name.text, owner);
392
+ }
393
+ if (ts.isArrayBindingPattern(node.name)) {
394
+ const [getter, setter] = node.name.elements;
395
+ if (getter && ts.isBindingElement(getter) && ts.isIdentifier(getter.name)) {
396
+ getters.add(getter.name.text);
397
+ owners.set(getter.name.text, owner);
398
+ }
399
+ if (setter && ts.isBindingElement(setter) && ts.isIdentifier(setter.name)) {
400
+ setters.add(setter.name.text);
401
+ owners.set(setter.name.text, owner);
402
+ }
403
+ }
404
+ });
405
+ return {
406
+ getters,
407
+ setters,
408
+ owners
409
+ };
410
+ }
411
+ function identifierIsReadAsValue(node) {
412
+ const parent = node.parent;
413
+ if (ts.isCallExpression(parent) && parent.expression === node) return false;
414
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return false;
415
+ if (ts.isVariableDeclaration(parent) && parent.name === node) return false;
416
+ if (ts.isBindingElement(parent) && parent.name === node) return false;
417
+ if (ts.isImportSpecifier(parent) || ts.isImportClause(parent) || ts.isNamespaceImport(parent)) return false;
418
+ if (ts.isJsxExpression(parent)) return !ts.isJsxAttribute(parent.parent);
419
+ return ts.isReturnStatement(parent);
420
+ }
421
+ const stateAccessRule = {
422
+ id: "askr/state-access",
423
+ category: "correctness",
424
+ severity: "error",
425
+ description: "State getters must be called and state setters require a value or updater.",
426
+ analyze(context) {
427
+ const diagnostics = [];
428
+ for (const sourceFile of context.sourceFiles) {
429
+ const state = collectStateBindings(sourceFile, sourceBindings(sourceFile));
430
+ visit(sourceFile, (node) => {
431
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && state.setters.has(node.expression.text) && node.arguments.length === 0) diagnostics.push(diagnostic(context, node.expression, this, `State setter '${node.expression.text}' is called without a value or updater.`, "Pass the next value or a functional updater."));
432
+ else if (ts.isIdentifier(node) && state.getters.has(node.text) && identifierIsReadAsValue(node)) diagnostics.push(diagnostic(context, node, this, `State getter '${node.text}' is used as a value instead of being called.`, `Read it with ${node.text}().`));
433
+ });
434
+ }
435
+ return diagnostics;
436
+ }
437
+ };
438
+ const stateRenderWriteRule = {
439
+ id: "askr/state-render-write",
440
+ category: "correctness",
441
+ severity: "error",
442
+ description: "State must not be mutated during component render.",
443
+ analyze(context) {
444
+ const diagnostics = [];
445
+ for (const sourceFile of context.sourceFiles) {
446
+ const state = collectStateBindings(sourceFile, sourceBindings(sourceFile));
447
+ visit(sourceFile, (node) => {
448
+ if (!ts.isCallExpression(node)) return;
449
+ let cellName = null;
450
+ if (ts.isIdentifier(node.expression) && state.setters.has(node.expression.text)) cellName = node.expression.text;
451
+ else if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "set" && ts.isIdentifier(node.expression.expression) && state.getters.has(node.expression.expression.text)) cellName = node.expression.expression.text;
452
+ if (!cellName) return;
453
+ const declarationOwner = state.owners.get(cellName);
454
+ if (!declarationOwner || containingFunction(node) !== declarationOwner) return;
455
+ diagnostics.push(diagnostic(context, node.expression, this, `State '${cellName}' is mutated during component render.`, "Move the update to an event handler, task, or other post-render operation."));
456
+ });
457
+ }
458
+ return diagnostics;
459
+ }
460
+ };
461
+ function functionBodyText(node) {
462
+ return ts.isArrowFunction(node) || ts.isFunctionExpression(node) ? node.body.getText() : "";
463
+ }
464
+ const resourceCancellationRule = {
465
+ id: "askr/resource-cancellation",
466
+ category: "correctness",
467
+ severity: "warning",
468
+ description: "Resource loaders should forward their AbortSignal to cancellable requests.",
469
+ analyze(context) {
470
+ const diagnostics = [];
471
+ for (const sourceFile of context.sourceFiles) {
472
+ const bindings = sourceBindings(sourceFile);
473
+ visit(sourceFile, (node) => {
474
+ if (!ts.isCallExpression(node) || canonicalCallName(node.expression, bindings) !== "resource") return;
475
+ const loader = node.arguments[0];
476
+ if (!loader || !ts.isArrowFunction(loader) && !ts.isFunctionExpression(loader)) {
477
+ diagnostics.push(diagnostic(context, node.expression, this, "resource() requires a loader function.", "Pass a loader that accepts { signal } and returns the resource value."));
478
+ return;
479
+ }
480
+ const text = functionBodyText(loader);
481
+ if (!/\bfetch\s*\(/.test(text)) return;
482
+ const firstParameter = loader.parameters[0];
483
+ const parameterText = firstParameter?.name.getText() ?? "";
484
+ const signalName = firstParameter && ts.isObjectBindingPattern(firstParameter.name) ? firstParameter.name.elements.find((element) => element.propertyName && element.propertyName.getText() === "signal" || element.name.getText() === "signal")?.name.getText() : parameterText ? `${parameterText}.signal` : null;
485
+ if (!signalName || !text.includes(signalName)) diagnostics.push(diagnostic(context, loader, this, "This resource loader calls fetch() without forwarding its AbortSignal.", "Accept { signal } and pass signal in the fetch options."));
486
+ });
487
+ }
488
+ return diagnostics;
489
+ }
490
+ };
491
+ function hasUnstableDependency(deps) {
492
+ if (!ts.isArrayLiteralExpression(deps)) return true;
493
+ return deps.elements.some((element) => ts.isObjectLiteralExpression(element) || ts.isArrayLiteralExpression(element) || ts.isArrowFunction(element) || ts.isFunctionExpression(element) || ts.isNewExpression(element));
494
+ }
495
+ const stableDependenciesRule = {
496
+ id: "askr/stable-dependencies",
497
+ category: "performance",
498
+ severity: "warning",
499
+ description: "Resource dependency entries should have stable identity.",
500
+ analyze(context) {
501
+ const diagnostics = [];
502
+ for (const sourceFile of context.sourceFiles) {
503
+ const bindings = sourceBindings(sourceFile);
504
+ visit(sourceFile, (node) => {
505
+ if (!ts.isCallExpression(node)) return;
506
+ const name = canonicalCallName(node.expression, bindings);
507
+ let deps;
508
+ if (name === "resource") deps = node.arguments[1];
509
+ else if (name === "stream") {
510
+ const options = node.arguments[1];
511
+ if (options && ts.isObjectLiteralExpression(options)) deps = propertyInitializer(objectProperty(options, "deps"));
512
+ }
513
+ if (!deps || !hasUnstableDependency(deps)) return;
514
+ diagnostics.push(diagnostic(context, deps, this, `${name}() dependencies contain a render-time allocation with unstable identity.`, "Depend on primitive values or stable references."));
515
+ });
516
+ }
517
+ return diagnostics;
518
+ }
519
+ };
520
+ function jsxAttributes(node) {
521
+ return new Map(node.attributes.properties.flatMap((attribute) => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) ? [[attribute.name.text, attribute]] : []));
522
+ }
523
+ const forContractRule = {
524
+ id: "askr/for-contract",
525
+ category: "correctness",
526
+ severity: "error",
527
+ description: "For requires an each source and exactly one explicit key strategy.",
528
+ analyze(context) {
529
+ const diagnostics = [];
530
+ for (const sourceFile of context.sourceFiles) {
531
+ const bindings = sourceBindings(sourceFile);
532
+ visit(sourceFile, (node) => {
533
+ if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
534
+ if (canonicalJsxName(node.tagName, bindings) !== "For") return;
535
+ const attributes = jsxAttributes(node);
536
+ if (!attributes.has("each")) diagnostics.push(diagnostic(context, node.tagName, this, "<For> is missing its required each source.", "Pass an array or accessor with each={...}."));
537
+ const by = attributes.get("by");
538
+ const byIndex = attributes.get("byIndex");
539
+ if (!by && !byIndex) diagnostics.push(diagnostic(context, node.tagName, this, "<For> requires a stable by function or explicit byIndex.", "Prefer by={(item) => item.id}; use byIndex only for positional lists."));
540
+ else if (by && byIndex) diagnostics.push(diagnostic(context, byIndex, this, "<For> accepts either by or byIndex, not both.", "Remove one key strategy."));
541
+ const element = ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent) ? node.parent : null;
542
+ if (ts.isJsxSelfClosingElement(node) || element && element.children.every((child) => ts.isJsxText(child) && child.text.trim().length === 0)) diagnostics.push(diagnostic(context, node.tagName, this, "<For> is missing its item renderer child.", "Provide a function child such as {(item) => <Row item={item} />}."));
543
+ });
544
+ }
545
+ return diagnostics;
546
+ }
547
+ };
548
+ const controlContractRule = {
549
+ id: "askr/control-contract",
550
+ category: "correctness",
551
+ severity: "error",
552
+ description: "Show, Case, and Match must satisfy their structural JSX contracts.",
553
+ analyze(context) {
554
+ const diagnostics = [];
555
+ for (const sourceFile of context.sourceFiles) {
556
+ const bindings = sourceBindings(sourceFile);
557
+ visit(sourceFile, (node) => {
558
+ if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
559
+ const name = canonicalJsxName(node.tagName, bindings);
560
+ if (name !== "Show" && name !== "Match" && name !== "Case") return;
561
+ const attributes = jsxAttributes(node);
562
+ if ((name === "Show" || name === "Match") && !attributes.has("when")) diagnostics.push(diagnostic(context, node.tagName, this, `<${name}> is missing its required when condition.`, "Pass a value or reactive accessor with when={...}."));
563
+ if (name === "Match") {
564
+ const parentElement = (ts.isJsxOpeningElement(node) && ts.isJsxElement(node.parent) ? node.parent : node).parent;
565
+ const parentOpening = ts.isJsxElement(parentElement) ? parentElement.openingElement : null;
566
+ if (!parentOpening || canonicalJsxName(parentOpening.tagName, bindings) !== "Case") diagnostics.push(diagnostic(context, node.tagName, this, "<Match> may only be used as a direct child of <Case>.", "Move this branch directly inside a <Case> boundary."));
567
+ }
568
+ });
569
+ }
570
+ return diagnostics;
571
+ }
572
+ };
573
+ const stableKeyRule = {
574
+ id: "askr/stable-key",
575
+ category: "performance",
576
+ severity: "warning",
577
+ description: "Dynamic lists should use stable data keys instead of positions.",
578
+ analyze(context) {
579
+ const diagnostics = [];
580
+ for (const sourceFile of context.sourceFiles) {
581
+ const bindings = sourceBindings(sourceFile);
582
+ visit(sourceFile, (node) => {
583
+ if (!ts.isJsxOpeningElement(node) && !ts.isJsxSelfClosingElement(node)) return;
584
+ if (canonicalJsxName(node.tagName, bindings) !== "For") return;
585
+ const by = jsxAttributes(node).get("by");
586
+ if (!by || !ts.isJsxAttribute(by) || !by.initializer || !ts.isJsxExpression(by.initializer)) return;
587
+ const expression = by.initializer.expression;
588
+ if (!expression || !ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression)) return;
589
+ const indexName = expression.parameters[1]?.name.getText();
590
+ const bodyText = expression.body.getText();
591
+ if (indexName && bodyText === indexName) diagnostics.push(diagnostic(context, expression.body, this, "<For> uses its item index as a key, which is unstable across insertions.", "Use a stable identifier from the item, or spell byIndex explicitly for a positional list."));
592
+ });
593
+ }
594
+ return diagnostics;
595
+ }
596
+ };
597
+ function reactiveMapReceiver(expression, stateGetters) {
598
+ if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression)) return stateGetters.has(expression.expression.text);
599
+ if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression)) return reactiveMapReceiver(expression.expression.expression, stateGetters);
600
+ if (ts.isPropertyAccessExpression(expression)) return reactiveMapReceiver(expression.expression, stateGetters);
601
+ return false;
602
+ }
603
+ const preferForRule = {
604
+ id: "askr/prefer-for",
605
+ category: "performance",
606
+ severity: "warning",
607
+ description: "Reactive JSX collections should use For for keyed reconciliation.",
608
+ analyze(context) {
609
+ const diagnostics = [];
610
+ for (const sourceFile of context.sourceFiles) {
611
+ const state = collectStateBindings(sourceFile, sourceBindings(sourceFile));
612
+ visit(sourceFile, (node) => {
613
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "map" || !reactiveMapReceiver(node.expression.expression, state.getters) || !node.parent || !ts.isJsxExpression(node.parent)) return;
614
+ diagnostics.push(diagnostic(context, node.expression.name, this, "A reactive collection is rendered with .map(), bypassing keyed <For> reconciliation.", "Render it with <For each={...} by={...}>. This semantic rewrite is report-only."));
615
+ });
616
+ }
617
+ return diagnostics;
618
+ }
619
+ };
620
+ function containsJsx(node) {
621
+ let found = false;
622
+ const walk = (candidate) => {
623
+ if (found) return;
624
+ if (ts.isJsxElement(candidate) || ts.isJsxSelfClosingElement(candidate)) {
625
+ found = true;
626
+ return;
627
+ }
628
+ ts.forEachChild(candidate, walk);
629
+ };
630
+ walk(node);
631
+ return found;
632
+ }
633
+ function isAsyncFunction(node) {
634
+ return (ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0)?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
635
+ }
636
+ function resolvedFunction(expression, context) {
637
+ if (!expression) return null;
638
+ if (ts.isArrowFunction(expression) || ts.isFunctionExpression(expression) || ts.isFunctionDeclaration(expression)) return expression;
639
+ if (!ts.isIdentifier(expression)) return null;
640
+ let symbol = context.checker.getSymbolAtLocation(expression);
641
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias) !== 0) symbol = context.checker.getAliasedSymbol(symbol);
642
+ const declaration = symbol?.valueDeclaration ?? symbol?.declarations?.[0];
643
+ if (declaration && ts.isFunctionDeclaration(declaration)) return declaration;
644
+ if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return declaration.initializer;
645
+ return null;
646
+ }
647
+ const asyncComponentRule = {
648
+ id: "askr/no-async-component",
649
+ category: "correctness",
650
+ severity: "error",
651
+ description: "Askr components render synchronously.",
652
+ analyze(context) {
653
+ const diagnostics = [];
654
+ for (const sourceFile of context.sourceFiles) {
655
+ const bindings = sourceBindings(sourceFile);
656
+ visit(sourceFile, (node) => {
657
+ let name;
658
+ let asyncToken = false;
659
+ if (ts.isFunctionDeclaration(node)) {
660
+ name = node.name;
661
+ asyncToken = node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
662
+ } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) {
663
+ name = node.name;
664
+ asyncToken = node.initializer.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
665
+ }
666
+ if (!name || !asyncToken || !/^[A-Z]/.test(name.text) || !containsJsx(node)) return;
667
+ diagnostics.push(diagnostic(context, name, this, `Component '${name.text}' is async, but Askr components must return synchronously.`, "Load data with resource(), route data, or server prefetching."));
668
+ });
669
+ visit(sourceFile, (node) => {
670
+ if (!ts.isCallExpression(node)) return;
671
+ const name = canonicalCallName(node.expression, bindings);
672
+ const componentIndex = name === "route" || name === "page" ? 1 : name === "index" || name === "fallback" ? 0 : -1;
673
+ if (componentIndex < 0) return;
674
+ const component = resolvedFunction(node.arguments[componentIndex], context);
675
+ if (!component || !isAsyncFunction(component)) return;
676
+ diagnostics.push(diagnostic(context, node.arguments[componentIndex], this, `Route component passed to ${name}() is async.`, "Keep the component synchronous and use route data, lazy(), or resource() for async work."));
677
+ });
678
+ }
679
+ return diagnostics;
680
+ }
681
+ };
682
+ function routeRegistrationAncestor(node, definitions) {
683
+ for (let current = node.parent; current; current = current.parent) if (ts.isFunctionLike(current) && definitions.has(current)) return true;
684
+ return false;
685
+ }
686
+ const routeRegistryRule = {
687
+ id: "askr/route-registry",
688
+ category: "correctness",
689
+ severity: "error",
690
+ description: "Route DSL calls must be synchronous and owned by a route registry.",
691
+ analyze(context) {
692
+ const diagnostics = [];
693
+ const routeCalls = /* @__PURE__ */ new Set([
694
+ "route",
695
+ "page",
696
+ "index",
697
+ "group",
698
+ "fallback"
699
+ ]);
700
+ const definitions = /* @__PURE__ */ new Set();
701
+ for (const sourceFile of context.sourceFiles) {
702
+ const bindings = sourceBindings(sourceFile);
703
+ visit(sourceFile, (node) => {
704
+ if (ts.isCallExpression(node) && canonicalCallName(node.expression, bindings) === "createRouteRegistry") {
705
+ const definition = resolvedFunction(node.arguments[0], context);
706
+ if (definition) definitions.add(definition);
707
+ }
708
+ });
709
+ }
710
+ for (const definition of definitions) ts.forEachChild(definition, function walk(node) {
711
+ if (ts.isCallExpression(node)) {
712
+ const called = resolvedFunction(node.expression, context);
713
+ if (called) definitions.add(called);
714
+ }
715
+ ts.forEachChild(node, walk);
716
+ });
717
+ for (const sourceFile of context.sourceFiles) {
718
+ const bindings = sourceBindings(sourceFile);
719
+ visit(sourceFile, (node) => {
720
+ if (!ts.isCallExpression(node)) return;
721
+ const name = canonicalCallName(node.expression, bindings);
722
+ if (name === "createRouteRegistry") {
723
+ const definition = node.arguments[0];
724
+ const resolved = resolvedFunction(definition, context);
725
+ if (!resolved) diagnostics.push(diagnostic(context, node.expression, this, "createRouteRegistry() requires a synchronous definition callback.", "Pass () => { ...route declarations... }."));
726
+ else if (isAsyncFunction(resolved)) diagnostics.push(diagnostic(context, definition ?? node.expression, this, "createRouteRegistry() cannot use an async definition callback.", "Keep route declaration synchronous and use lazy(), loaders, or resources for async work."));
727
+ return;
728
+ }
729
+ if (!name || !routeCalls.has(name) || routeRegistrationAncestor(node, definitions)) return;
730
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() is declared outside createRouteRegistry().`, "Move route DSL calls into the synchronous createRouteRegistry(() => { ... }) callback."));
731
+ });
732
+ }
733
+ return diagnostics;
734
+ }
735
+ };
736
+ const routePathRule = {
737
+ id: "askr/route-path-syntax",
738
+ category: "correctness",
739
+ severity: "error",
740
+ description: "Askr route parameters use {name} segments.",
741
+ analyze(context) {
742
+ const diagnostics = [];
743
+ const pathCalls = /* @__PURE__ */ new Set(["route", "page"]);
744
+ for (const sourceFile of context.sourceFiles) {
745
+ const bindings = sourceBindings(sourceFile);
746
+ visit(sourceFile, (node) => {
747
+ if (!ts.isCallExpression(node)) return;
748
+ const name = canonicalCallName(node.expression, bindings);
749
+ const first = node.arguments[0];
750
+ if (!name || !pathCalls.has(name) || !first || !ts.isStringLiteral(first)) return;
751
+ if (!/:([^/{}]+)/.test(first.text)) return;
752
+ const replacement = first.text.replace(/:([^/{}]+)/g, "{$1}");
753
+ diagnostics.push(diagnostic(context, first, this, `Route path '${first.text}' uses colon parameters instead of {name} segments.`, `Use '${replacement}'.`, {
754
+ description: "Convert colon route parameters to Askr {name} segments",
755
+ filePath: sourceFile.fileName,
756
+ start: first.getStart(sourceFile),
757
+ end: first.getEnd(),
758
+ replacement: JSON.stringify(replacement)
759
+ }));
760
+ });
761
+ }
762
+ return diagnostics;
763
+ }
764
+ };
765
+ function propertyInitializer(property) {
766
+ if (!property) return void 0;
767
+ return ts.isPropertyAssignment(property) ? property.initializer : property.name;
768
+ }
769
+ function loaderForDataCall(name, call) {
770
+ const options = call.arguments[0];
771
+ if (!options || !ts.isObjectLiteralExpression(options)) return void 0;
772
+ return propertyInitializer(name === "createQuery" ? objectProperty(options, "fetch") : objectProperty(options, "action"));
773
+ }
774
+ const dataCancellationRule = {
775
+ id: "askr/data-cancellation",
776
+ category: "correctness",
777
+ severity: "warning",
778
+ description: "Query and mutation requests should forward their cancellation signal.",
779
+ analyze(context) {
780
+ const diagnostics = [];
781
+ for (const sourceFile of context.sourceFiles) {
782
+ const bindings = sourceBindings(sourceFile);
783
+ visit(sourceFile, (node) => {
784
+ if (!ts.isCallExpression(node)) return;
785
+ const name = canonicalCallName(node.expression, bindings);
786
+ if (name !== "createQuery" && name !== "createMutation") return;
787
+ const loader = loaderForDataCall(name, node);
788
+ if (!loader || !ts.isArrowFunction(loader) && !ts.isFunctionExpression(loader)) return;
789
+ const body = functionBodyText(loader);
790
+ if (!/\bfetch\s*\(/.test(body) || /\bsignal\b/.test(body)) return;
791
+ diagnostics.push(diagnostic(context, loader, this, `${name}() performs fetch() without forwarding its cancellation signal.`, "Accept the operation context signal and include it in the fetch options."));
792
+ });
793
+ }
794
+ return diagnostics;
795
+ }
796
+ };
797
+ function objectProperty(object, name) {
798
+ return object.properties.find((property) => (ts.isPropertyAssignment(property) || ts.isShorthandPropertyAssignment(property)) && property.name.getText().replace(/^['"]|['"]$/g, "") === name);
799
+ }
800
+ function literalString(expression) {
801
+ if (!expression) return null;
802
+ if (ts.isStringLiteralLike(expression)) return expression.text;
803
+ return null;
804
+ }
805
+ function isNullishLiteral(expression) {
806
+ return expression.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(expression) && expression.text === "undefined";
807
+ }
808
+ function isProvablyNonFunction(expression) {
809
+ return Boolean(expression && (ts.isLiteralExpression(expression) || isNullishLiteral(expression) || ts.isObjectLiteralExpression(expression) || ts.isArrayLiteralExpression(expression)));
810
+ }
811
+ function numericConstant(expression) {
812
+ if (!expression) return null;
813
+ if (ts.isNumericLiteral(expression)) return Number(expression.text);
814
+ if (ts.isPrefixUnaryExpression(expression) && (expression.operator === ts.SyntaxKind.MinusToken || expression.operator === ts.SyntaxKind.PlusToken)) {
815
+ const operand = numericConstant(expression.operand);
816
+ return operand === null ? null : expression.operator === ts.SyntaxKind.MinusToken ? -operand : operand;
817
+ }
818
+ if (ts.isIdentifier(expression)) {
819
+ if (expression.text === "NaN") return NaN;
820
+ if (expression.text === "Infinity") return Number.POSITIVE_INFINITY;
821
+ }
822
+ if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === "Number" && [
823
+ "NaN",
824
+ "POSITIVE_INFINITY",
825
+ "NEGATIVE_INFINITY"
826
+ ].includes(expression.name.text)) return Number[expression.name.text];
827
+ return null;
828
+ }
829
+ function invalidPositiveInterval(expression) {
830
+ const value = numericConstant(expression);
831
+ return value !== null && (!Number.isFinite(value) || value <= 0);
832
+ }
833
+ function optionExpression(object, name) {
834
+ return propertyInitializer(objectProperty(object, name));
835
+ }
836
+ function invalidAsyncIterableReturn(source) {
837
+ if (!ts.isArrowFunction(source) && !ts.isFunctionExpression(source)) return null;
838
+ if (source.asteriskToken) return null;
839
+ let returned;
840
+ if (ts.isArrowFunction(source) && !ts.isBlock(source.body)) returned = source.body;
841
+ else if (ts.isBlock(source.body)) {
842
+ const returns = source.body.statements.filter(ts.isReturnStatement);
843
+ if (returns.length === 0) return source;
844
+ if (returns.length !== 1) return null;
845
+ returned = returns[0]?.expression;
846
+ }
847
+ if (!returned) return source;
848
+ if (ts.isNumericLiteral(returned) || ts.isStringLiteralLike(returned) || isNullishLiteral(returned) || ts.isArrayLiteralExpression(returned)) return returned;
849
+ if (ts.isCallExpression(returned) && ts.isPropertyAccessExpression(returned.expression) && ts.isIdentifier(returned.expression.expression) && returned.expression.expression.text === "Promise" && returned.expression.name.text === "resolve") {
850
+ const resolved = returned.arguments[0];
851
+ if (resolved && (ts.isLiteralExpression(resolved) || isNullishLiteral(resolved) || ts.isArrayLiteralExpression(resolved))) return resolved;
852
+ }
853
+ return null;
854
+ }
855
+ const lifecycleContractRule = {
856
+ id: "askr/lifecycle-contract",
857
+ category: "correctness",
858
+ severity: "error",
859
+ description: "Lifecycle primitives require valid targets, events, callbacks, and intervals.",
860
+ analyze(context) {
861
+ const diagnostics = [];
862
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) if (name === "on") {
863
+ const [target, event, handler] = node.arguments;
864
+ if (!target || isNullishLiteral(target) || ts.isLiteralExpression(target)) diagnostics.push(diagnostic(context, target ?? node.expression, this, "on() requires an EventTarget as its first argument.", "Pass the concrete event target owned by this component."));
865
+ if (!event || literalString(event) !== null && literalString(event)?.trim() === "") diagnostics.push(diagnostic(context, event ?? node.expression, this, "on() requires a non-empty event name.", "Pass a concrete event name such as 'click'."));
866
+ else if (ts.isLiteralExpression(event) && !ts.isStringLiteralLike(event)) diagnostics.push(diagnostic(context, event, this, "on() event names must be strings.", "Pass a non-empty string event name."));
867
+ if (!handler || isProvablyNonFunction(handler)) diagnostics.push(diagnostic(context, handler ?? node.expression, this, "on() requires an event handler function.", "Pass a function as the third argument."));
868
+ } else if (name === "timer") {
869
+ const [interval, callback] = node.arguments;
870
+ if (!interval) diagnostics.push(diagnostic(context, node.expression, this, "timer() requires a positive finite interval.", "Pass a positive interval in milliseconds."));
871
+ else if (invalidPositiveInterval(interval)) diagnostics.push(diagnostic(context, interval, this, "timer() interval must be positive and finite.", "Use a finite interval greater than zero."));
872
+ if (!callback || isProvablyNonFunction(callback)) diagnostics.push(diagnostic(context, callback ?? node.expression, this, "timer() requires a callback function.", "Pass the work to run as the second argument."));
873
+ } else if (name === "task") {
874
+ const callback = node.arguments[0];
875
+ if (!callback || isProvablyNonFunction(callback)) diagnostics.push(diagnostic(context, callback ?? node.expression, this, "task() requires a function.", "Pass a function that performs the lifecycle-owned work."));
876
+ }
877
+ return diagnostics;
878
+ }
879
+ };
880
+ const streamContractRule = {
881
+ id: "askr/stream-contract",
882
+ category: "correctness",
883
+ severity: "error",
884
+ description: "stream requires a source function returning an AsyncIterable and valid options.",
885
+ analyze(context) {
886
+ const diagnostics = [];
887
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
888
+ if (name !== "stream") continue;
889
+ const source = node.arguments[0];
890
+ if (!source || isProvablyNonFunction(source)) diagnostics.push(diagnostic(context, source ?? node.expression, this, "stream() requires a source function.", "Pass ({ signal }) => AsyncIterable or a Promise of one."));
891
+ else {
892
+ const invalidReturn = invalidAsyncIterableReturn(source);
893
+ if (invalidReturn) diagnostics.push(diagnostic(context, invalidReturn, this, "stream() source has a return shape that is not an AsyncIterable.", "Return an async generator or another AsyncIterable."));
894
+ }
895
+ const options = node.arguments[1];
896
+ if (!options) continue;
897
+ if (!ts.isObjectLiteralExpression(options)) {
898
+ if (ts.isLiteralExpression(options) || isNullishLiteral(options) || ts.isArrayLiteralExpression(options)) diagnostics.push(diagnostic(context, options, this, "stream() options must be an object.", "Pass { deps, initialValue } or omit the options argument."));
899
+ continue;
900
+ }
901
+ const deps = optionExpression(options, "deps");
902
+ if (deps && !ts.isArrayLiteralExpression(deps) && (ts.isLiteralExpression(deps) || isNullishLiteral(deps) || ts.isObjectLiteralExpression(deps))) diagnostics.push(diagnostic(context, deps, this, "stream() deps must be an array.", "Pass a readonly dependency array."));
903
+ }
904
+ return diagnostics;
905
+ }
906
+ };
907
+ const dataContractRule = {
908
+ id: "askr/data-contract",
909
+ category: "correctness",
910
+ severity: "error",
911
+ description: "Queries and mutations require their identifying and executable options.",
912
+ analyze(context) {
913
+ const diagnostics = [];
914
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
915
+ if (name !== "createQuery" && name !== "createMutation") continue;
916
+ const options = node.arguments[0];
917
+ if (!options) {
918
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() requires an options object.`, name === "createQuery" ? "Pass a non-empty key and fetch function." : "Pass an action function."));
919
+ continue;
920
+ }
921
+ if (!ts.isObjectLiteralExpression(options)) {
922
+ if (ts.isLiteralExpression(options) || isNullishLiteral(options) || ts.isArrayLiteralExpression(options)) diagnostics.push(diagnostic(context, options, this, `${name}() options must be an object or a declared query definition.`));
923
+ continue;
924
+ }
925
+ if (name === "createQuery") {
926
+ const key = optionExpression(options, "key");
927
+ if (!key || literalString(key) !== null && literalString(key)?.trim() === "") diagnostics.push(diagnostic(context, key ?? options, this, "createQuery() requires a non-empty key.", "Pass a stable query key."));
928
+ else if (ts.isLiteralExpression(key) && !ts.isStringLiteralLike(key)) diagnostics.push(diagnostic(context, key, this, "createQuery() key must be a string or key function."));
929
+ const fetcher = optionExpression(options, "fetch");
930
+ if (!fetcher || isProvablyNonFunction(fetcher)) diagnostics.push(diagnostic(context, fetcher ?? options, this, "createQuery() requires a fetch function.", "Pass a cancellable fetch function."));
931
+ } else {
932
+ const action = optionExpression(options, "action");
933
+ if (!action || isProvablyNonFunction(action)) diagnostics.push(diagnostic(context, action ?? options, this, "createMutation() requires an action function.", "Pass the mutation implementation as action."));
934
+ }
935
+ }
936
+ return diagnostics;
937
+ }
938
+ };
939
+ const invalidationContractRule = {
940
+ id: "askr/invalidation-contract",
941
+ category: "correctness",
942
+ severity: "error",
943
+ description: "Invalidation prefixes, scopes, and intervals must be concrete and non-empty.",
944
+ analyze(context) {
945
+ const diagnostics = [];
946
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
947
+ if (![
948
+ "invalidate",
949
+ "queryScope",
950
+ "invalidateOnInterval"
951
+ ].includes(name)) continue;
952
+ const prefix = node.arguments[0];
953
+ if (!prefix || literalString(prefix) !== null && literalString(prefix)?.trim() === "" || ts.isLiteralExpression(prefix) && !ts.isStringLiteralLike(prefix)) diagnostics.push(diagnostic(context, prefix ?? node.expression, this, `${name}() requires a non-empty string ${name === "queryScope" ? "namespace" : "prefix"}.`, "Use a stable non-empty query-key prefix."));
954
+ if (name !== "invalidateOnInterval") continue;
955
+ const options = node.arguments[1];
956
+ if (!options || !ts.isObjectLiteralExpression(options)) {
957
+ if (!options || ts.isLiteralExpression(options) || isNullishLiteral(options) || ts.isArrayLiteralExpression(options)) diagnostics.push(diagnostic(context, options ?? node.expression, this, "invalidateOnInterval() requires an options object with intervalMs."));
958
+ continue;
959
+ }
960
+ const interval = optionExpression(options, "intervalMs");
961
+ if (!interval || invalidPositiveInterval(interval)) diagnostics.push(diagnostic(context, interval ?? options, this, "invalidateOnInterval() intervalMs must be positive and finite.", "Pass a finite interval greater than zero."));
962
+ }
963
+ return diagnostics;
964
+ }
965
+ };
966
+ function validateIslandObject(context, rule, object, diagnostics) {
967
+ const root = optionExpression(object, "root");
968
+ const component = optionExpression(object, "component");
969
+ if (!root || isNullishLiteral(root) || literalString(root)?.trim() === "") diagnostics.push(diagnostic(context, root ?? object, rule, "Island configuration requires a root.", "Pass a root element or non-empty selector."));
970
+ if (!component || isProvablyNonFunction(component)) diagnostics.push(diagnostic(context, component ?? object, rule, "Island configuration requires a component function.", "Pass a synchronous Askr component."));
971
+ else {
972
+ const resolved = resolvedFunction(component, context);
973
+ if (resolved && isAsyncFunction(resolved)) diagnostics.push(diagnostic(context, component, rule, "Island components must be synchronous.", "Move asynchronous work into resource(), stream(), or task()."));
974
+ }
975
+ const routes = objectProperty(object, "routes");
976
+ if (routes) diagnostics.push(diagnostic(context, routes, rule, "Island configuration cannot include routes.", "Use createSPA() for routed applications."));
977
+ }
978
+ const islandContractRule = {
979
+ id: "askr/island-contract",
980
+ category: "correctness",
981
+ severity: "error",
982
+ description: "Island boot requires roots, synchronous components, and no routes.",
983
+ analyze(context) {
984
+ const diagnostics = [];
985
+ for (const sourceFile of context.sourceFiles) for (const { node, name } of sourceFacts(sourceFile).calls) {
986
+ if (name !== "createIsland" && name !== "createIslands") continue;
987
+ const config = node.arguments[0];
988
+ if (!config || !ts.isObjectLiteralExpression(config)) {
989
+ if (!config || ts.isLiteralExpression(config) || isNullishLiteral(config) || ts.isArrayLiteralExpression(config)) diagnostics.push(diagnostic(context, config ?? node.expression, this, `${name}() requires a configuration object.`));
990
+ continue;
991
+ }
992
+ if (name === "createIsland") {
993
+ validateIslandObject(context, this, config, diagnostics);
994
+ continue;
995
+ }
996
+ const islands = optionExpression(config, "islands");
997
+ if (!islands || ts.isArrayLiteralExpression(islands) && islands.elements.length === 0) diagnostics.push(diagnostic(context, islands ?? config, this, "createIslands() requires a non-empty islands array."));
998
+ else if (ts.isArrayLiteralExpression(islands)) {
999
+ for (const island of islands.elements) if (ts.isObjectLiteralExpression(island)) validateIslandObject(context, this, island, diagnostics);
1000
+ }
1001
+ }
1002
+ return diagnostics;
1003
+ }
1004
+ };
1005
+ const executionModelRule = {
1006
+ id: "askr/execution-model",
1007
+ category: "correctness",
1008
+ severity: "error",
1009
+ description: "A workspace must not mix routed SPA boot and island boot.",
1010
+ analyze(context) {
1011
+ const routed = [];
1012
+ const islands = [];
1013
+ for (const sourceFile of context.sourceFiles) for (const fact of sourceFacts(sourceFile).calls) {
1014
+ if (fact.name === "createSPA" || fact.name === "hydrateSPA") routed.push(fact);
1015
+ if (fact.name === "createIsland" || fact.name === "createIslands") islands.push(fact);
1016
+ }
1017
+ if (routed.length === 0 || islands.length === 0) return [];
1018
+ return [diagnostic(context, islands[0].node.expression, this, "This workspace mixes SPA/hydration boot with island boot.", "Choose one execution model per workspace and move the other boot entry to a separate workspace.")];
1019
+ }
1020
+ };
1021
+ function invalidPrefixElements(expression) {
1022
+ if (!expression || !ts.isArrayLiteralExpression(expression)) return [];
1023
+ return expression.elements.filter((element) => ts.isExpression(element) && (literalString(element) !== null && literalString(element)?.trim() === "" || ts.isLiteralExpression(element) && !ts.isStringLiteralLike(element)));
1024
+ }
1025
+ const actionContractRule = {
1026
+ id: "askr/action-contract",
1027
+ category: "correctness",
1028
+ severity: "error",
1029
+ description: "Actions require stable unique IDs, schemas, and valid invalidation prefixes.",
1030
+ analyze(context) {
1031
+ const diagnostics = [];
1032
+ const literalIds = /* @__PURE__ */ new Map();
1033
+ for (const sourceFile of context.sourceFiles) {
1034
+ const facts = sourceFacts(sourceFile);
1035
+ for (const { node, name } of facts.calls) {
1036
+ if (name !== "defineAction") continue;
1037
+ const options = node.arguments[0];
1038
+ if (!options || !ts.isObjectLiteralExpression(options)) {
1039
+ diagnostics.push(diagnostic(context, options ?? node.expression, this, "defineAction() requires an options object with id and input."));
1040
+ continue;
1041
+ }
1042
+ const id = optionExpression(options, "id");
1043
+ const idText = literalString(id);
1044
+ if (!id || idText !== null && idText.trim() === "") diagnostics.push(diagnostic(context, id ?? options, this, "defineAction() requires a non-empty id.", "Use a stable workspace-unique action ID."));
1045
+ else if (idText !== null) if (literalIds.has(idText)) diagnostics.push(diagnostic(context, id, this, `Action ID '${idText}' is declared more than once in this workspace.`, "Give every action a unique stable ID."));
1046
+ else literalIds.set(idText, id);
1047
+ else if (ts.isLiteralExpression(id)) diagnostics.push(diagnostic(context, id, this, "defineAction() id must be a string."));
1048
+ const input = optionExpression(options, "input");
1049
+ if (!input || isNullishLiteral(input)) diagnostics.push(diagnostic(context, input ?? options, this, "defineAction() requires an input schema.", "Pass an object schema as input."));
1050
+ for (const invalid of invalidPrefixElements(optionExpression(options, "invalidates"))) diagnostics.push(diagnostic(context, invalid, this, "Action invalidation prefixes must be non-empty strings."));
1051
+ }
1052
+ for (const { node, name } of facts.jsx) {
1053
+ if (name !== "ActionForm") continue;
1054
+ if (!jsxAttributes(node).has("action")) diagnostics.push(diagnostic(context, node.tagName, this, "<ActionForm> requires an action descriptor.", "Pass action={descriptor}."));
1055
+ }
1056
+ }
1057
+ return diagnostics;
1058
+ }
1059
+ };
1060
+ const actionPromiseRule = {
1061
+ id: "askr/action-promise",
1062
+ category: "correctness",
1063
+ severity: "error",
1064
+ description: "Action submit promises must be observed or explicitly discarded.",
1065
+ analyze(context) {
1066
+ const diagnostics = [];
1067
+ for (const sourceFile of context.sourceFiles) {
1068
+ const facts = sourceFacts(sourceFile);
1069
+ const handles = /* @__PURE__ */ new Set();
1070
+ for (const { node, name } of facts.calls) {
1071
+ if (name !== "action") continue;
1072
+ const parent = node.parent;
1073
+ if (ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) handles.add(parent.name.text);
1074
+ }
1075
+ for (const node of facts.allCalls) {
1076
+ if (!ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== "submit") continue;
1077
+ const receiver = node.expression.expression;
1078
+ if (!(ts.isIdentifier(receiver) && handles.has(receiver.text) || ts.isCallExpression(receiver) && canonicalCallName(receiver.expression, facts.bindings) === "action") || !ts.isExpressionStatement(node.parent)) continue;
1079
+ diagnostics.push(diagnostic(context, node.expression.name, this, "Action submit Promise is discarded.", "Await it, return it, or use void when fire-and-forget is intentional."));
1080
+ }
1081
+ }
1082
+ return diagnostics;
1083
+ }
1084
+ };
1085
+ function allocationName(expression) {
1086
+ if (ts.isIdentifier(expression) && [
1087
+ "RegExp",
1088
+ "Map",
1089
+ "Set"
1090
+ ].includes(expression.text)) return expression.text;
1091
+ if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === "Intl") return `Intl.${expression.name.text}`;
1092
+ return null;
1093
+ }
1094
+ const renderAllocationRule = {
1095
+ id: "askr/render-allocation",
1096
+ category: "performance",
1097
+ severity: "info",
1098
+ description: "Repeated render-time constructors should be hoisted or memoized.",
1099
+ analyze(context) {
1100
+ const diagnostics = [];
1101
+ for (const sourceFile of context.sourceFiles) for (const construction of sourceFacts(sourceFile).constructions) {
1102
+ const name = allocationName(construction.expression);
1103
+ if (!name) continue;
1104
+ const owner = containingFunction(construction);
1105
+ if (!owner || !/^[A-Z]/.test(functionName(owner) ?? "") && !containsJsx(owner)) continue;
1106
+ diagnostics.push(diagnostic(context, construction, this, `new ${name}() is allocated during component render.`, "Hoist stable instances or cache them outside the repeated render path."));
1107
+ }
1108
+ return diagnostics;
1109
+ }
1110
+ };
1111
+ const bootRegistryRule = {
1112
+ id: "askr/boot-registry",
1113
+ category: "correctness",
1114
+ severity: "error",
1115
+ description: "Routed app boot requires an explicit route registry.",
1116
+ analyze(context) {
1117
+ const diagnostics = [];
1118
+ for (const sourceFile of context.sourceFiles) {
1119
+ const bindings = sourceBindings(sourceFile);
1120
+ visit(sourceFile, (node) => {
1121
+ if (!ts.isCallExpression(node)) return;
1122
+ const name = canonicalCallName(node.expression, bindings);
1123
+ if (name !== "createSPA" && name !== "hydrateSPA") return;
1124
+ const config = node.arguments[0];
1125
+ if (!config || !ts.isObjectLiteralExpression(config)) {
1126
+ diagnostics.push(diagnostic(context, node.expression, this, `${name}() must receive an object containing registry.`, "Pass the RouteRegistry returned by createRouteRegistry() as registry."));
1127
+ return;
1128
+ }
1129
+ if (!objectProperty(config, "registry")) {
1130
+ const legacy = objectProperty(config, "routes") ?? objectProperty(config, "manifest");
1131
+ diagnostics.push(diagnostic(context, legacy ?? config, this, legacy ? `${name}() uses a legacy route source instead of registry.` : `${name}() is missing its required registry property.`, "Pass the RouteRegistry returned by createRouteRegistry() as registry."));
1132
+ }
1133
+ const parent = node.parent;
1134
+ if (ts.isExpressionStatement(parent) && !ts.isAwaitExpression(node.parent) && !ts.isVoidExpression(node.parent)) diagnostics.push(diagnostic(context, node.expression, this, `${name}() returns a Promise that is not awaited.`, `Use await ${name}(...), return the Promise, or explicitly use void when fire-and-forget is intentional.`));
1135
+ });
1136
+ }
1137
+ return diagnostics;
1138
+ }
1139
+ };
1140
+ function isTypeofGuard(node) {
1141
+ return ts.isTypeOfExpression(node.parent);
1142
+ }
1143
+ function expressionContainsTypeofName(expression, name) {
1144
+ let found = false;
1145
+ const walk = (node) => {
1146
+ if (found) return;
1147
+ if (ts.isTypeOfExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name) {
1148
+ found = true;
1149
+ return;
1150
+ }
1151
+ ts.forEachChild(node, walk);
1152
+ };
1153
+ walk(expression);
1154
+ return found;
1155
+ }
1156
+ function isInsideTypeofGuard(node) {
1157
+ for (let current = node.parent; current; current = current.parent) {
1158
+ if (ts.isIfStatement(current) && expressionContainsTypeofName(current.expression, node.text)) return true;
1159
+ if (ts.isConditionalExpression(current) && expressionContainsTypeofName(current.condition, node.text)) return true;
1160
+ if (ts.isFunctionLike(current) || ts.isSourceFile(current)) break;
1161
+ }
1162
+ return false;
1163
+ }
1164
+ const ssrGlobalsRule = {
1165
+ id: "askr/ssr-browser-global",
1166
+ category: "correctness",
1167
+ severity: "error",
1168
+ description: "SSR code must not access browser-only globals during rendering.",
1169
+ analyze(context) {
1170
+ const diagnostics = [];
1171
+ const globals = /* @__PURE__ */ new Set([
1172
+ "window",
1173
+ "document",
1174
+ "localStorage",
1175
+ "sessionStorage",
1176
+ "navigator"
1177
+ ]);
1178
+ for (const sourceFile of context.sourceFiles) {
1179
+ const normalized = sourceFile.fileName.split(path.sep).join("/");
1180
+ if (!sourceFile.statements.some((statement) => ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && /^@askrjs\/askr\/(?:ssr|ssg)$/.test(statement.moduleSpecifier.text)) && !/(?:^|\/)(?:server|entry-server|ssr|ssg)[^/]*\.[cm]?[jt]sx?$/.test(normalized)) continue;
1181
+ visit(sourceFile, (node) => {
1182
+ if (!ts.isIdentifier(node) || !globals.has(node.text) || isTypeofGuard(node) || isInsideTypeofGuard(node)) return;
1183
+ if (ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) return;
1184
+ if ((ts.isPropertyAssignment(node.parent) || ts.isMethodDeclaration(node.parent) || ts.isPropertyDeclaration(node.parent) || ts.isPropertySignature(node.parent)) && node.parent.name === node) return;
1185
+ if (context.checker.getSymbolAtLocation(node)?.declarations?.some((declaration) => !declaration.getSourceFile().isDeclarationFile || !/[\\/]typescript[\\/]lib[\\/]lib\./.test(declaration.getSourceFile().fileName))) return;
1186
+ diagnostics.push(diagnostic(context, node, this, `Browser global '${node.text}' is accessed in SSR/SSG code.`, "Move the access to client lifecycle code or guard it behind a browser-only branch."));
1187
+ });
1188
+ }
1189
+ return diagnostics;
1190
+ }
1191
+ };
1192
+ function dependencyRecord(manifest, section) {
1193
+ const value = manifest[section];
1194
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
1195
+ }
1196
+ function hasDependency(manifest, packageName) {
1197
+ return [
1198
+ "dependencies",
1199
+ "devDependencies",
1200
+ "peerDependencies",
1201
+ "optionalDependencies"
1202
+ ].some((section) => packageName in dependencyRecord(manifest, section));
1203
+ }
1204
+ const ANALYZE_RULES = [
1205
+ {
1206
+ id: "askr/parse-error",
1207
+ category: "correctness",
1208
+ severity: "error",
1209
+ description: "Source must parse before framework analysis is reliable.",
1210
+ analyze(context) {
1211
+ return context.program.getSyntacticDiagnostics().filter((entry) => Boolean(entry.file && context.sourceFiles.some((sourceFile) => sourceFile.fileName === entry.file?.fileName))).map((entry) => {
1212
+ const start = entry.start ?? 0;
1213
+ const point = entry.file.getLineAndCharacterOfPosition(start);
1214
+ return {
1215
+ ruleId: this.id,
1216
+ category: this.category,
1217
+ severity: this.severity,
1218
+ message: ts.flattenDiagnosticMessageText(entry.messageText, "\n"),
1219
+ workspace: context.workspace.name,
1220
+ file: workspaceRelativeFile(context, entry.file.fileName),
1221
+ line: point.line + 1,
1222
+ column: point.character + 1,
1223
+ remediation: "Fix the syntax error so framework rules can inspect this file reliably."
1224
+ };
1225
+ });
1226
+ }
1227
+ },
1228
+ stableRenderRule,
1229
+ stateAccessRule,
1230
+ stateRenderWriteRule,
1231
+ resourceCancellationRule,
1232
+ stableDependenciesRule,
1233
+ lifecycleContractRule,
1234
+ streamContractRule,
1235
+ dataContractRule,
1236
+ invalidationContractRule,
1237
+ forContractRule,
1238
+ controlContractRule,
1239
+ stableKeyRule,
1240
+ preferForRule,
1241
+ asyncComponentRule,
1242
+ routeRegistryRule,
1243
+ routePathRule,
1244
+ dataCancellationRule,
1245
+ bootRegistryRule,
1246
+ islandContractRule,
1247
+ executionModelRule,
1248
+ actionContractRule,
1249
+ actionPromiseRule,
1250
+ renderAllocationRule,
1251
+ ssrGlobalsRule,
1252
+ {
1253
+ id: "askr/framework-config",
1254
+ category: "configuration",
1255
+ severity: "error",
1256
+ description: "TypeScript and Vite must use Askr's JSX/runtime wiring.",
1257
+ analyze(context) {
1258
+ const diagnostics = [];
1259
+ const tsx = context.sourceFiles.find((sourceFile) => sourceFile.fileName.endsWith(".tsx"));
1260
+ if (!tsx || !hasDependency(context.workspace.manifest, "@askrjs/askr")) return diagnostics;
1261
+ const tsconfigPath = path.join(context.workspace.directory, "tsconfig.json");
1262
+ const tsconfigSource = ts.sys.readFile(tsconfigPath);
1263
+ if (context.program.getCompilerOptions().jsxImportSource !== "@askrjs/askr") {
1264
+ let fix;
1265
+ if (tsconfigSource) try {
1266
+ const parsed = JSON.parse(tsconfigSource);
1267
+ parsed.compilerOptions = {
1268
+ ...parsed.compilerOptions && typeof parsed.compilerOptions === "object" && !Array.isArray(parsed.compilerOptions) ? parsed.compilerOptions : {},
1269
+ jsx: "react-jsx",
1270
+ jsxImportSource: "@askrjs/askr"
1271
+ };
1272
+ fix = {
1273
+ description: "Configure TypeScript to use the Askr JSX runtime",
1274
+ filePath: tsconfigPath,
1275
+ start: 0,
1276
+ end: tsconfigSource.length,
1277
+ replacement: `${JSON.stringify(parsed, null, 2)}\n`
1278
+ };
1279
+ } catch {}
1280
+ diagnostics.push(diagnostic(context, tsx, this, "TSX is present but compilerOptions.jsxImportSource is not '@askrjs/askr'.", "Set jsx to react-jsx and jsxImportSource to @askrjs/askr.", fix));
1281
+ }
1282
+ const viteConfig = context.sourceFiles.find((sourceFile) => /(?:^|\/)vite\.config\.[cm]?[jt]s$/.test(sourceFile.fileName.split(path.sep).join("/")));
1283
+ if (viteConfig) {
1284
+ let pluginLocalName = null;
1285
+ for (const statement of viteConfig.statements) {
1286
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "@askrjs/vite" || !statement.importClause?.namedBindings || !ts.isNamedImports(statement.importClause.namedBindings)) continue;
1287
+ pluginLocalName = statement.importClause.namedBindings.elements.find((element) => (element.propertyName?.text ?? element.name.text) === "askr")?.name.text ?? null;
1288
+ }
1289
+ let pluginCalled = false;
1290
+ if (pluginLocalName) visit(viteConfig, (node) => {
1291
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === pluginLocalName) pluginCalled = true;
1292
+ });
1293
+ const dependencyPresent = hasDependency(context.workspace.manifest, "@askrjs/vite");
1294
+ if (dependencyPresent && pluginCalled) return diagnostics;
1295
+ diagnostics.push(diagnostic(context, viteConfig, this, !dependencyPresent ? "This Askr Vite project does not declare @askrjs/vite." : "Vite is configured without calling the @askrjs/vite askr() plugin.", "Declare @askrjs/vite, import askr, and include askr() in the plugin list."));
1296
+ }
1297
+ return diagnostics;
1298
+ }
1299
+ }
1300
+ ];
1301
+ function configuredSeverity(rule, configuration) {
1302
+ return configuration.rules[rule.id] ?? rule.severity;
1303
+ }
1304
+ //#endregion
1305
+ //#region src/analyze/runner.ts
1306
+ function rootManifest(workspaces) {
1307
+ const root = workspaces.find((workspace) => workspace.isRoot);
1308
+ if (!root) throw new Error("Discovered project is missing its root workspace.");
1309
+ return root.manifest;
1310
+ }
1311
+ function compareDiagnostics(left, right) {
1312
+ return left.workspace.localeCompare(right.workspace) || left.file.localeCompare(right.file) || left.line - right.line || left.column - right.column || left.ruleId.localeCompare(right.ruleId) || left.message.localeCompare(right.message);
1313
+ }
1314
+ async function analyzePass(root, allWorkspaces, selectedWorkspaces, manifest) {
1315
+ const baseConfiguration = readAnalyzeConfiguration(manifest);
1316
+ const diagnostics = [];
1317
+ const workspaces = [];
1318
+ for (const workspace of selectedWorkspaces) {
1319
+ const nestedWorkspaceExclusions = allWorkspaces.flatMap((candidate) => {
1320
+ if (candidate.directory === workspace.directory) return [];
1321
+ const relative = path.relative(workspace.directory, candidate.directory).split(path.sep).join("/");
1322
+ return relative && !relative.startsWith("../") ? [`${relative}/**`] : [];
1323
+ });
1324
+ const configuration = {
1325
+ ...baseConfiguration,
1326
+ exclude: [...baseConfiguration.exclude, ...nestedWorkspaceExclusions]
1327
+ };
1328
+ const created = await createWorkspaceAnalysisContext(root, workspace, configuration);
1329
+ workspaces.push({
1330
+ name: workspace.name,
1331
+ path: path.relative(root, workspace.directory).split(path.sep).join("/") || ".",
1332
+ tsconfig: created.tsconfig ? path.relative(root, created.tsconfig).split(path.sep).join("/") : null,
1333
+ files: created.context.sourceFiles.length
1334
+ });
1335
+ for (const rule of ANALYZE_RULES) {
1336
+ const severity = configuredSeverity(rule, configuration);
1337
+ if (severity === "off") continue;
1338
+ diagnostics.push(...rule.analyze(created.context).map((entry) => ({
1339
+ ...entry,
1340
+ severity
1341
+ })));
1342
+ }
1343
+ }
1344
+ diagnostics.sort(compareDiagnostics);
1345
+ return {
1346
+ diagnostics,
1347
+ workspaces
1348
+ };
1349
+ }
1350
+ function fixResult(root, diagnostic, reason) {
1351
+ if (!diagnostic.fix) throw new Error("Cannot record a missing analysis fix.");
1352
+ return {
1353
+ ruleId: diagnostic.ruleId,
1354
+ workspace: diagnostic.workspace,
1355
+ file: path.relative(root, diagnostic.fix.filePath).split(path.sep).join("/"),
1356
+ description: diagnostic.fix.description,
1357
+ ...reason ? { reason } : {}
1358
+ };
1359
+ }
1360
+ async function prepareFixes(root, diagnostics) {
1361
+ const candidates = diagnostics.filter((entry) => Boolean(entry.fix));
1362
+ const byFile = /* @__PURE__ */ new Map();
1363
+ for (const candidate of candidates) {
1364
+ const list = byFile.get(candidate.fix.filePath) ?? [];
1365
+ list.push(candidate);
1366
+ byFile.set(candidate.fix.filePath, list);
1367
+ }
1368
+ const changes = [];
1369
+ const applied = [];
1370
+ const skipped = [];
1371
+ for (const [filePath, entries] of [...byFile].sort(([left], [right]) => left.localeCompare(right))) {
1372
+ const original = await fs.readFile(filePath, "utf8");
1373
+ const ordered = [...entries].sort((left, right) => right.fix.start - left.fix.start || right.fix.end - left.fix.end || left.ruleId.localeCompare(right.ruleId));
1374
+ let content = original;
1375
+ let nextStart = original.length + 1;
1376
+ for (const entry of ordered) {
1377
+ const fix = entry.fix;
1378
+ if (fix.start < 0 || fix.end < fix.start || fix.end > original.length || fix.end > nextStart) {
1379
+ skipped.push(fixResult(root, entry, "conflicts with another safe fix"));
1380
+ continue;
1381
+ }
1382
+ content = `${content.slice(0, fix.start)}${fix.replacement}${content.slice(fix.end)}`;
1383
+ nextStart = fix.start;
1384
+ applied.push(fixResult(root, entry));
1385
+ }
1386
+ if (content !== original) changes.push({
1387
+ filePath,
1388
+ content
1389
+ });
1390
+ }
1391
+ return {
1392
+ changes,
1393
+ applied,
1394
+ skipped
1395
+ };
1396
+ }
1397
+ function publicDiagnostic(diagnostic) {
1398
+ const { fix, ...entry } = diagnostic;
1399
+ return {
1400
+ ...entry,
1401
+ ...fix ? { fix: {
1402
+ description: fix.description,
1403
+ safe: true
1404
+ } } : {}
1405
+ };
1406
+ }
1407
+ function summary(diagnostics, applied, skipped) {
1408
+ return {
1409
+ errors: diagnostics.filter((entry) => entry.severity === "error").length,
1410
+ warnings: diagnostics.filter((entry) => entry.severity === "warning").length,
1411
+ info: diagnostics.filter((entry) => entry.severity === "info").length,
1412
+ diagnostics: diagnostics.length,
1413
+ appliedFixes: applied.length,
1414
+ skippedFixes: skipped.length
1415
+ };
1416
+ }
1417
+ function analysisHasBlockingFindings(report) {
1418
+ return report.summary.errors > 0 || report.summary.warnings > 0;
1419
+ }
1420
+ async function runAnalysis(options) {
1421
+ const project = await discoverWorkspaceProject({
1422
+ cwd: options.cwd,
1423
+ workspacePatterns: options.workspacePatterns
1424
+ });
1425
+ const manifest = rootManifest(project.workspaces);
1426
+ let pass = await analyzePass(project.root, project.workspaces, project.selectedWorkspaces, manifest);
1427
+ let applied = [];
1428
+ let skipped = [];
1429
+ if (options.check) skipped = pass.diagnostics.filter((entry) => entry.fix).map((entry) => fixResult(project.root, entry, "check mode does not write files"));
1430
+ else {
1431
+ const prepared = await prepareFixes(project.root, pass.diagnostics);
1432
+ skipped = prepared.skipped;
1433
+ if (prepared.changes.length > 0) {
1434
+ await (options.writer ?? writeFileChanges)(prepared.changes);
1435
+ applied = prepared.applied;
1436
+ pass = await analyzePass(project.root, project.workspaces, project.selectedWorkspaces, manifest);
1437
+ }
1438
+ }
1439
+ return {
1440
+ schemaVersion: 1,
1441
+ root: project.root,
1442
+ discoveredWorkspaces: project.workspaces.map((workspace) => workspace.name),
1443
+ selectedWorkspaces: project.selectedWorkspaces.map((workspace) => workspace.name),
1444
+ workspaces: pass.workspaces,
1445
+ appliedFixes: applied,
1446
+ skippedFixes: skipped,
1447
+ diagnostics: pass.diagnostics.map(publicDiagnostic),
1448
+ summary: summary(pass.diagnostics, applied, skipped)
1449
+ };
1450
+ }
1451
+ //#endregion
1452
+ export { analysisHasBlockingFindings, runAnalysis };