larrylint 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { name, version } from "./package.mjs";
2
2
  import process from "node:process";
3
3
  import { loadConfig } from "c12";
4
- import { basename, dirname, join, normalize, parse, relative, resolve } from "pathe";
4
+ import { dirname, join, normalize, parse, relative, resolve } from "pathe";
5
5
  import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import tsParser, { parse as parse$1 } from "@typescript-eslint/parser";
7
7
  import { parse as parse$2 } from "vue-eslint-parser";
@@ -17,76 +17,15 @@ async function loadLarrylintConfig(cwd = process.cwd()) {
17
17
  globalRc: false,
18
18
  dotenv: false
19
19
  });
20
- return {
21
- sharedDomains: config.sharedDomains ?? [],
22
- heavyPackages: config.heavyPackages ?? []
23
- };
24
- }
25
- const EXTENSIONS = [
26
- "",
27
- ".ts",
28
- ".mts",
29
- ".cts",
30
- ".js",
31
- ".mjs",
32
- ".cjs",
33
- "/index.ts",
34
- "/index.js"
35
- ];
36
- const directories = /* @__PURE__ */ new Map();
37
- function cachedReader(parse) {
38
- const cache = /* @__PURE__ */ new Map();
39
- return (path) => {
40
- let mtimeMs;
41
- try {
42
- mtimeMs = statSync(path).mtimeMs;
43
- } catch {
44
- return;
45
- }
46
- const cached = cache.get(path);
47
- if (cached?.mtimeMs === mtimeMs) return cached.value;
48
- const value = parse(readFileSync(path, "utf8"));
49
- cache.set(path, {
50
- mtimeMs,
51
- value
52
- });
53
- return value;
54
- };
20
+ return { heavyPackages: config.heavyPackages ?? [] };
55
21
  }
56
- function isDirectory(path) {
57
- let result = directories.get(path);
58
- if (result === void 0) {
59
- try {
60
- result = statSync(path).isDirectory();
61
- } catch {
62
- result = false;
63
- }
64
- directories.set(path, result);
65
- }
66
- return result;
67
- }
68
- function resolveImport(importer, source) {
69
- if (!source.startsWith("./") && !source.startsWith("../")) return;
70
- const target = resolve(dirname(importer), source.split("?")[0]);
71
- return isDirectory(target) ? `${target}/index` : target;
72
- }
73
- function resolveModule(importer, source) {
74
- if (!source.startsWith("./") && !source.startsWith("../")) return;
75
- const target = resolve(dirname(importer), source.split("?")[0]);
76
- return EXTENSIONS.map((extension) => `${target}${extension}`).find((path) => existsSync(path) && !isDirectory(path));
77
- }
78
- function findPackage(from, name) {
79
- for (let folder = from;; folder = dirname(folder)) {
80
- const candidate = join(folder, "node_modules", name);
81
- if (existsSync(join(candidate, "package.json"))) return realpathSync(candidate);
82
- if (dirname(folder) === folder) return;
83
- }
84
- }
85
- const HANDLER_FILE = /\.(query|resolver|link|action|template|page-index)(?:\.[cm]?[jt]s)?$/;
22
+ const HANDLER_FILE = /\.(query|resolver|link|action|templates?|page-index)(?:\.[cm]?[jt]s)?$/;
86
23
  const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
87
24
  const APP_FOLDERS = {
88
25
  "sections": "section",
26
+ "section": "section",
89
27
  "blocks": "block",
28
+ "block": "block",
90
29
  "components": "component",
91
30
  "composables": "composable",
92
31
  "utils": "app-util",
@@ -129,10 +68,6 @@ function classify(file) {
129
68
  test
130
69
  };
131
70
  }
132
- function domainOf(file) {
133
- if (!file.domain || file.kind === "handler" || file.kind === "orchestr-file") return file.domain;
134
- return isDirectory(`${file.root}/src/runtime/server/orchestr/${file.domain}`) ? file.domain : void 0;
135
- }
136
71
  function classifyRuntime(parts) {
137
72
  const [side, folder = "", ...rest] = parts;
138
73
  if (parts.length < 2 || side !== "app" && side !== "server" && side !== "shared") return { side: "other" };
@@ -144,27 +79,83 @@ function classifyRuntime(parts) {
144
79
  side,
145
80
  kind: APP_FOLDERS[folder] ?? "other"
146
81
  };
147
- const nested = rest.length > 1;
148
82
  if (folder === "orchestr") {
149
- if (nested && rest[0] === "plugins") return {
83
+ if (rest.length > 1 && rest[0] === "plugins") return {
150
84
  side,
151
85
  kind: "orchestr-plugin"
152
86
  };
153
- const handler = HANDLER_FILE.exec(parts.at(-1))?.[1];
87
+ const handler = HANDLER_FILE.exec(parts.at(-1))?.[1]?.replace("templates", "template");
154
88
  return {
155
89
  side,
156
90
  kind: handler ? "handler" : "orchestr-file",
157
- handler,
158
- domain: nested ? rest[0] : void 0
91
+ handler
159
92
  };
160
93
  }
161
- const kind = SERVER_FOLDERS[folder] ?? "other";
162
94
  return {
163
95
  side,
164
- kind,
165
- domain: kind === "server-util" && nested ? rest[0] : void 0
96
+ kind: SERVER_FOLDERS[folder] ?? "other"
166
97
  };
167
98
  }
99
+ const EXTENSIONS = [
100
+ "",
101
+ ".ts",
102
+ ".mts",
103
+ ".cts",
104
+ ".js",
105
+ ".mjs",
106
+ ".cjs",
107
+ "/index.ts",
108
+ "/index.js"
109
+ ];
110
+ const directories = /* @__PURE__ */ new Map();
111
+ function cachedReader(parse) {
112
+ const cache = /* @__PURE__ */ new Map();
113
+ return (path) => {
114
+ let mtimeMs;
115
+ try {
116
+ mtimeMs = statSync(path).mtimeMs;
117
+ } catch {
118
+ return;
119
+ }
120
+ const cached = cache.get(path);
121
+ if (cached?.mtimeMs === mtimeMs) return cached.value;
122
+ const value = parse(readFileSync(path, "utf8"));
123
+ cache.set(path, {
124
+ mtimeMs,
125
+ value
126
+ });
127
+ return value;
128
+ };
129
+ }
130
+ function isDirectory(path) {
131
+ let result = directories.get(path);
132
+ if (result === void 0) {
133
+ try {
134
+ result = statSync(path).isDirectory();
135
+ } catch {
136
+ result = false;
137
+ }
138
+ directories.set(path, result);
139
+ }
140
+ return result;
141
+ }
142
+ function resolveImport(importer, source) {
143
+ if (!source.startsWith("./") && !source.startsWith("../")) return;
144
+ const target = resolve(dirname(importer), source.split("?")[0]);
145
+ return isDirectory(target) ? `${target}/index` : target;
146
+ }
147
+ function resolveModule(importer, source) {
148
+ if (!source.startsWith("./") && !source.startsWith("../")) return;
149
+ const target = resolve(dirname(importer), source.split("?")[0]);
150
+ return EXTENSIONS.map((extension) => `${target}${extension}`).find((path) => existsSync(path) && !isDirectory(path));
151
+ }
152
+ function findPackage(from, name) {
153
+ for (let folder = from;; folder = dirname(folder)) {
154
+ const candidate = join(folder, "node_modules", name);
155
+ if (existsSync(join(candidate, "package.json"))) return realpathSync(candidate);
156
+ if (dirname(folder) === folder) return;
157
+ }
158
+ }
168
159
  const BASELINE_FILE = "larrylint-baseline.json";
169
160
  const readBaselineFile = cachedReader((text) => JSON.parse(text));
170
161
  function readBaseline(root) {
@@ -257,47 +248,6 @@ function defineRule(rule) {
257
248
  }
258
249
  };
259
250
  }
260
- function findProperty(object, key) {
261
- for (const entry of object.properties) if (entry.type === "Property" && !entry.computed && (entry.key.type === "Identifier" && entry.key.name === key || entry.key.type === "Literal" && entry.key.value === key)) return entry.value;
262
- }
263
- function findStringProperty(object, key) {
264
- const value = findProperty(object, key);
265
- return value?.type === "Literal" && typeof value.value === "string" ? {
266
- value: value.value,
267
- node: value
268
- } : void 0;
269
- }
270
- function objectElements(node) {
271
- if (node?.type !== "ArrayExpression") return [];
272
- return node.elements.filter((element) => element?.type === "ObjectExpression");
273
- }
274
- const ISO_4217 = /^[A-Z]{3}$/;
275
- var money_default = defineRule({
276
- meta: {
277
- type: "problem",
278
- docs: { description: "Require money amounts in minor units and ISO 4217 currency codes." },
279
- schema: [],
280
- messages: {
281
- minorUnits: "Money amounts are in minor units, e.g. 1999 for 19.99, so this price is off by a factor of 100.",
282
- currency: "Money currencies are ISO 4217 codes like 'EUR', and formatters show '{{currency}}' as it is."
283
- }
284
- },
285
- applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
286
- create: ({ report }) => ({ ObjectExpression: (node) => {
287
- const amount = findProperty(node, "amount");
288
- const currency = findProperty(node, "currency");
289
- if (!amount || !currency) return;
290
- if (amount.type === "Literal" && typeof amount.value === "number" && !Number.isInteger(amount.value)) report({
291
- node: amount,
292
- messageId: "minorUnits"
293
- });
294
- if (currency.type === "Literal" && typeof currency.value === "string" && !ISO_4217.test(currency.value)) report({
295
- node: currency,
296
- messageId: "currency",
297
- data: { currency: currency.value }
298
- });
299
- } })
300
- });
301
251
  const WRAPPERS = /* @__PURE__ */ new Set([
302
252
  "ChainExpression",
303
253
  "TSNonNullExpression",
@@ -314,15 +264,6 @@ function chainRoot(node) {
314
264
  else if (current.type === "MemberExpression") current = current.object;
315
265
  else return current.type === "Identifier" ? current : void 0;
316
266
  }
317
- function chainMethods(node) {
318
- const methods = [];
319
- let current = node;
320
- while (current?.type === "CallExpression" && current.callee.type === "MemberExpression" && current.callee.property.type === "Identifier") {
321
- methods.push(current.callee.property.name);
322
- current = current.callee.object;
323
- }
324
- return methods;
325
- }
326
267
  function memberPath(node) {
327
268
  if (node.type === "Identifier") return [node.name];
328
269
  if (node.type !== "MemberExpression") return [];
@@ -359,6 +300,37 @@ function followPromise(promise) {
359
300
  end = call;
360
301
  }
361
302
  }
303
+ const TEST_DIRECTIVES = /* @__PURE__ */ new Set([
304
+ "if",
305
+ "else-if",
306
+ "show"
307
+ ]);
308
+ function isReference(node) {
309
+ const { parent } = node;
310
+ if (parent?.type === "MemberExpression") return parent.property !== node || parent.computed;
311
+ return parent?.type !== "Property" || parent.key !== node || parent.computed || parent.shorthand;
312
+ }
313
+ function isNullish(node) {
314
+ return node.type === "Identifier" && node.name === "undefined" || node.type === "Literal" && node.value === null && !("regex" in node);
315
+ }
316
+ function canBeNullish(value) {
317
+ if (isNullish(value)) return true;
318
+ if (value.type === "LogicalExpression" && value.operator === "??") return canBeNullish(value.right);
319
+ if (value.type === "ConditionalExpression") return canBeNullish(value.consequent) || canBeNullish(value.alternate);
320
+ return false;
321
+ }
322
+ function isTested(node) {
323
+ const parent = node.parent;
324
+ switch (parent?.type) {
325
+ case "IfStatement":
326
+ case "WhileStatement":
327
+ case "ConditionalExpression": return parent.test === node;
328
+ case "LogicalExpression": return parent.left === node;
329
+ case "UnaryExpression": return parent.operator === "!";
330
+ case "VExpressionContainer": return parent.parent?.type === "VAttribute" && TEST_DIRECTIVES.has(parent.parent.key?.name?.name ?? "");
331
+ default: return false;
332
+ }
333
+ }
362
334
  const SKIPPED = /* @__PURE__ */ new Set([
363
335
  "parent",
364
336
  "loc",
@@ -412,16 +384,28 @@ function isCaught(node) {
412
384
  }
413
385
  return false;
414
386
  }
387
+ function canReturnNullish(fn, value) {
388
+ if (fn.body.type !== "BlockStatement") return fn.body !== value && canBeNullish(fn.body);
389
+ let nullish = false;
390
+ walkBody(fn, (node) => {
391
+ nullish ||= node.type === "ReturnStatement" && node.argument !== value && (!node.argument || canBeNullish(node.argument));
392
+ });
393
+ const last = fn.body.body.at(-1);
394
+ return nullish || last?.type !== "ReturnStatement" && last?.type !== "ThrowStatement";
395
+ }
415
396
  var mutations_default = defineRule({
416
397
  meta: {
417
398
  type: "problem",
418
399
  docs: { description: "Require error handling where a mutateAsync() promise is awaited or dropped, since a rejected mutation takes down its whole section or block." },
419
400
  schema: [],
420
- messages: { unhandled: "If this mutation fails, frontend-core replaces the whole section or block with its \"Retry\" state. Catch the error here with try/catch or .catch()." }
401
+ messages: {
402
+ unhandled: "If this mutation fails, frontend-core replaces the whole section or block with its \"Retry\" state, unless every caller catches the error. Catch it here with try/catch or .catch().",
403
+ dropped: "Nothing handles this promise, so a failed mutation ends up as an unhandled rejection without any feedback. Catch it with try/catch or .catch()."
404
+ }
421
405
  },
422
406
  applies: (file) => file.side === "app",
423
407
  create: ({ report, visitTemplate }) => {
424
- const check = (call) => {
408
+ const check = (call, template) => {
425
409
  if (nameOf(call.callee) !== "mutateAsync") return;
426
410
  const { end, handled } = followPromise(call);
427
411
  const { parent } = end;
@@ -431,146 +415,40 @@ var mutations_default = defineRule({
431
415
  if (!awaited && !dropped || awaited && isCaught(parent)) return;
432
416
  report({
433
417
  node: call.callee.type === "MemberExpression" ? call.callee.property : call.callee,
434
- messageId: "unhandled"
418
+ messageId: dropped && !template ? "dropped" : "unhandled"
435
419
  });
436
420
  };
437
- visitTemplate({ CallExpression: check });
438
- return { CallExpression: check };
421
+ visitTemplate({ CallExpression: (node) => check(node, true) });
422
+ return { CallExpression: (node) => check(node, false) };
439
423
  }
440
424
  });
441
- const COMPONENTS = /* @__PURE__ */ new Set([
442
- "section",
443
- "block",
444
- "component",
445
- "override"
446
- ]);
447
- var store_default = defineRule({
448
- meta: {
449
- type: "suggestion",
450
- docs: { description: "Keep useOrchestrStore() out of components, in composables." },
451
- schema: [],
452
- messages: { store: "Read orchestr data through a composable, so every component that shows it reads and refreshes it the same way. Move useOrchestrStore() into one." }
453
- },
454
- applies: (file) => COMPONENTS.has(file.kind ?? ""),
455
- create: ({ report }) => ({ CallExpression: (node) => {
456
- if (node.callee.type === "Identifier" && node.callee.name === "useOrchestrStore") report({
457
- node,
458
- messageId: "store"
459
- });
460
- } })
461
- });
425
+ const METHODS = /* @__PURE__ */ new Set(["resolve", "resolveOrThrow"]);
426
+ function destructuredResolvers(declarator) {
427
+ const methods = /* @__PURE__ */ new Map();
428
+ if (declarator.id.type !== "ObjectPattern" || !declarator.init || nameOf(declarator.init) !== "linkResolver") return methods;
429
+ for (const property of declarator.id.properties) {
430
+ const method = property.type === "Property" ? nameOf(property.key) : void 0;
431
+ if (method && METHODS.has(method) && property.type === "Property" && property.value.type === "Identifier") methods.set(property.value.name, method);
432
+ }
433
+ return methods;
434
+ }
435
+ function linkResolverMethod(call, destructured) {
436
+ const { callee } = call;
437
+ if (callee.type === "Identifier") return destructured.get(callee.name);
438
+ const method = callee.type === "MemberExpression" ? nameOf(callee) : void 0;
439
+ return method && METHODS.has(method) && callee.type === "MemberExpression" && nameOf(callee.object) === "linkResolver" ? method : void 0;
440
+ }
441
+ const NOT_PAGES = /^\/(?:\/|api\/|_|app-|\.well-known\/)/;
462
442
  function isInternalPath(path) {
463
- return path?.startsWith("/") === true && !path.startsWith("//") && !path.startsWith("/api/");
443
+ return path?.startsWith("/") === true && !NOT_PAGES.test(path);
464
444
  }
465
445
  function isHandBuiltPath(node) {
466
446
  if (node?.type === "TemplateLiteral") return node.expressions.length > 0 && isInternalPath(node.quasis[0]?.value.cooked);
467
447
  return node?.type === "BinaryExpression" && node.operator === "+" && node.left.type === "Literal" && typeof node.left.value === "string" && isInternalPath(node.left.value);
468
448
  }
469
- function renderedRoots(template) {
470
- return template.children.filter((child) => child.type === "VElement" && !child.startTag.attributes.some((attribute) => attribute.directive && (attribute.key.name.name === "else" || attribute.key.name.name === "else-if")));
471
- }
472
- function findAttribute(element, name) {
473
- return element.startTag.attributes.find((attribute) => attribute.directive ? attribute.key.name.name === "bind" && attribute.key.argument?.type === "VIdentifier" && attribute.key.argument.name === name : attribute.key.name === name);
474
- }
475
- function valueStart(attribute) {
476
- if (!attribute.directive) return attribute.value?.value;
477
- const expression = attribute.value?.expression;
478
- if (expression?.type === "Literal" && typeof expression.value === "string") return expression.value;
479
- return expression?.type === "TemplateLiteral" ? expression.quasis[0]?.value.cooked ?? void 0 : void 0;
480
- }
481
- var anchors_default = defineRule({
482
- meta: {
483
- type: "problem",
484
- docs: { description: "Disallow plain `<a>` tags for internal links, which reload the page and break Studio's navigation sync." },
485
- schema: [],
486
- messages: { anchor: "A plain <a> reloads the whole page and breaks Studio's navigation sync. Use <NuxtLink> for internal links." }
487
- },
488
- applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
489
- create: ({ report, visitTemplate }) => {
490
- visitTemplate({ VElement: (node) => {
491
- if (node.rawName !== "a") return;
492
- const href = findAttribute(node, "href");
493
- const target = findAttribute(node, "target");
494
- const external = target && !target.directive && target.value?.value === "_blank";
495
- if (href && isInternalPath(valueStart(href)) && !external) report({
496
- loc: href.loc,
497
- messageId: "anchor"
498
- });
499
- } });
500
- return {};
501
- }
502
- });
503
- const LINK_NAMES = /* @__PURE__ */ new Set([
504
- "to",
505
- "href",
506
- "link"
507
- ]);
508
- const NAVIGATE = /* @__PURE__ */ new Set([
509
- "navigateTo",
510
- "push",
511
- "replace"
512
- ]);
513
- var paths_default = defineRule({
514
- meta: {
515
- type: "problem",
516
- docs: { description: "Disallow hand-built internal paths, which break when a page type's route changes." },
517
- schema: [],
518
- messages: { path: "This path breaks when the page type's route changes, and Studio can't follow it. Resolve the link with linkResolver instead." }
519
- },
520
- applies: (file) => file.side === "app",
521
- create: ({ report, visitTemplate }) => {
522
- visitTemplate({ VAttribute: (node) => {
523
- const argument = node.directive && node.key.name.name === "bind" ? node.key.argument : null;
524
- const expression = node.directive ? node.value?.expression : void 0;
525
- if (argument?.type === "VIdentifier" && LINK_NAMES.has(argument.name) && isHandBuiltPath(expression)) report({
526
- node: expression,
527
- messageId: "path"
528
- });
529
- } });
530
- return {
531
- CallExpression: (node) => {
532
- const [target] = node.arguments;
533
- if (NAVIGATE.has(nameOf(node.callee) ?? "") && isHandBuiltPath(target)) report({
534
- node: target,
535
- messageId: "path"
536
- });
537
- },
538
- Property: (node) => {
539
- if (node.key.type === "Identifier" && LINK_NAMES.has(node.key.name) && isHandBuiltPath(node.value)) report({
540
- node: node.value,
541
- messageId: "path"
542
- });
543
- }
544
- };
545
- }
546
- });
547
- const TEST_DIRECTIVES = /* @__PURE__ */ new Set([
548
- "if",
549
- "else-if",
550
- "show"
551
- ]);
552
- function isReference(node) {
553
- const { parent } = node;
554
- if (parent?.type === "MemberExpression") return parent.property !== node || parent.computed;
555
- return parent?.type !== "Property" || parent.key !== node || parent.computed || parent.shorthand;
556
- }
557
- function canBeNullish(value) {
558
- if (value.type === "Identifier" && value.name === "undefined" || value.type === "Literal" && value.value === null && !("regex" in value)) return true;
559
- if (value.type === "LogicalExpression" && value.operator === "??") return canBeNullish(value.right);
560
- if (value.type === "ConditionalExpression") return canBeNullish(value.consequent) || canBeNullish(value.alternate);
561
- return false;
562
- }
563
- function isTested(node) {
564
- const parent = node.parent;
565
- switch (parent?.type) {
566
- case "IfStatement":
567
- case "WhileStatement":
568
- case "ConditionalExpression": return parent.test === node;
569
- case "LogicalExpression": return parent.left === node;
570
- case "UnaryExpression": return parent.operator === "!";
571
- case "VExpressionContainer": return parent.parent?.type === "VAttribute" && TEST_DIRECTIVES.has(parent.parent.key?.name?.name ?? "");
572
- default: return false;
573
- }
449
+ function isRouter(node) {
450
+ const name = nameOf(node.type === "CallExpression" ? node.callee : node);
451
+ return node.type === "CallExpression" ? name === "useRouter" : name === "router" || name === "$router";
574
452
  }
575
453
  const parseFile = cachedReader((text) => {
576
454
  try {
@@ -611,6 +489,22 @@ function findFunction(statements, name) {
611
489
  }
612
490
  }
613
491
  }
492
+ function findConstant(statements, name) {
493
+ for (const statement of statements) {
494
+ const declaration = statement.type === "ExportNamedDeclaration" && statement.declaration ? statement.declaration : statement;
495
+ if (declaration.type !== "VariableDeclaration" || declaration.kind !== "const") continue;
496
+ const declarator = declaration.declarations.find((item) => item.id.type === "Identifier" && item.id.name === name);
497
+ if (declarator?.init) return declarator.init;
498
+ }
499
+ }
500
+ function resolveConstant(name, program, filename) {
501
+ const local = findConstant(program.body, name);
502
+ if (local) return local;
503
+ const imported = findImport(program, name);
504
+ const file = imported && imported.name !== "*" ? resolveModule(filename, imported.source) : void 0;
505
+ const module = file ? parseFile(file) : void 0;
506
+ return module && imported ? findConstant(module.body, imported.name) : void 0;
507
+ }
614
508
  function findExport(program, name) {
615
509
  for (const statement of program.body) {
616
510
  if (statement.type === "ExportDefaultDeclaration" && name === "default") {
@@ -665,33 +559,138 @@ function usedProps(node) {
665
559
  }
666
560
  function findHolder(node) {
667
561
  let value = node;
562
+ let fn;
668
563
  const { parent } = node;
669
564
  if (parent?.type === "ReturnStatement") {
670
- let fn = parent.parent;
671
- while (fn && !isFunction(fn)) fn = fn.parent;
672
- value = fn ?? value;
673
- } else if (parent && isFunction(parent) && parent.body === node) value = parent;
565
+ let current = parent.parent;
566
+ while (current && !isFunction(current)) current = current.parent;
567
+ fn = current ?? void 0;
568
+ value = current ?? value;
569
+ } else if (parent && isFunction(parent) && parent.body === node) {
570
+ fn = parent;
571
+ value = parent;
572
+ }
674
573
  const computed = value.parent?.type === "CallExpression" && value.parent.callee.type === "Identifier" && value.parent.callee.name === "computed" ? value.parent : void 0;
675
574
  const declarator = (computed ?? value).parent;
676
575
  return declarator?.type === "VariableDeclarator" && declarator.id.type === "Identifier" ? {
677
576
  name: declarator.id.name,
678
- computed: computed !== void 0
577
+ computed: computed !== void 0,
578
+ fn
679
579
  } : void 0;
680
580
  }
681
581
  function importedComponent(program, filename, name) {
682
582
  const imported = findImport(program, name);
683
583
  return imported?.name === "default" && imported.source.endsWith(".vue") ? resolveModule(filename, imported.source) : void 0;
684
584
  }
585
+ function renderedRoots(template) {
586
+ return template.children.filter((child) => child.type === "VElement" && !child.startTag.attributes.some((attribute) => attribute.directive && (attribute.key.name.name === "else" || attribute.key.name.name === "else-if")));
587
+ }
588
+ function findAttribute(element, name) {
589
+ return element.startTag.attributes.find((attribute) => attribute.directive ? attribute.key.name.name === "bind" && attribute.key.argument?.type === "VIdentifier" && attribute.key.argument.name === name : attribute.key.name === name);
590
+ }
591
+ function valueStart(attribute) {
592
+ if (!attribute.directive) return attribute.value?.value;
593
+ const expression = attribute.value?.expression;
594
+ if (expression?.type === "Literal" && typeof expression.value === "string") return expression.value;
595
+ return expression?.type === "TemplateLiteral" ? expression.quasis[0]?.value.cooked ?? void 0 : void 0;
596
+ }
597
+ function rendersTag(element, tag) {
598
+ const is = element.rawName === "component" ? findAttribute(element, "is") : void 0;
599
+ if (element.rawName === tag || is && !is.directive && is.value?.value === tag) return true;
600
+ const expression = is?.directive ? is.value?.expression : void 0;
601
+ let found = false;
602
+ if (expression) walk(expression, (node) => {
603
+ found ||= node.type === "Literal" && node.value === tag;
604
+ });
605
+ return found;
606
+ }
607
+ var anchors_default = defineRule({
608
+ meta: {
609
+ type: "problem",
610
+ docs: { description: "Disallow plain `<a>` tags for internal and resolved links, which reload the page and break Studio's navigation sync." },
611
+ schema: [],
612
+ messages: { anchor: "A plain <a> reloads the whole page and breaks Studio's navigation sync. Use <NuxtLink> for internal links." }
613
+ },
614
+ applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
615
+ create: ({ report, visitTemplate }) => {
616
+ const destructured = /* @__PURE__ */ new Map();
617
+ const resolved = /* @__PURE__ */ new Set();
618
+ const resolves = (node) => node.type === "CallExpression" && (linkResolverMethod(node, destructured) !== void 0 || nameOf(node.callee) === "useResolvedLink");
619
+ visitTemplate({ VElement: (node) => {
620
+ const href = rendersTag(node, "a") ? findAttribute(node, "href") : void 0;
621
+ const target = findAttribute(node, "target");
622
+ if (!href || findAttribute(node, "download") || target && !target.directive && target.value?.value === "_blank") return;
623
+ const expression = href.directive ? href.value?.expression : void 0;
624
+ if (expression && (resolves(expression) || expression.type === "Identifier" && resolved.has(expression.name)) || isInternalPath(valueStart(href))) report({
625
+ loc: href.loc,
626
+ messageId: "anchor"
627
+ });
628
+ } });
629
+ return {
630
+ VariableDeclarator: (node) => {
631
+ destructuredResolvers(node).forEach((method, local) => destructured.set(local, method));
632
+ },
633
+ CallExpression: (node) => {
634
+ const holder = resolves(node) ? findHolder(node) : void 0;
635
+ if (holder) resolved.add(holder.name);
636
+ }
637
+ };
638
+ }
639
+ });
640
+ const LINK_NAMES = /* @__PURE__ */ new Set([
641
+ "to",
642
+ "href",
643
+ "link"
644
+ ]);
645
+ const ROUTER_METHODS = /* @__PURE__ */ new Set(["push", "replace"]);
646
+ var paths_default = defineRule({
647
+ meta: {
648
+ type: "problem",
649
+ docs: { description: "Disallow hand-built paths to pages, which miss the per-language paths and market prefixes Studio sets." },
650
+ schema: [],
651
+ messages: { path: "Page paths are set per page and language in Studio, and each market adds its own prefix, e.g. /en, so this path can lead to the wrong page. Resolve the link with linkResolver instead." }
652
+ },
653
+ applies: (file) => file.side === "app",
654
+ create: ({ report, visitTemplate }) => {
655
+ visitTemplate({ VAttribute: (node) => {
656
+ const argument = node.directive && node.key.name.name === "bind" ? node.key.argument : null;
657
+ const expression = node.directive ? node.value?.expression : void 0;
658
+ if (argument?.type === "VIdentifier" && LINK_NAMES.has(argument.name) && isHandBuiltPath(expression)) report({
659
+ node: expression,
660
+ messageId: "path"
661
+ });
662
+ } });
663
+ return {
664
+ CallExpression: (node) => {
665
+ const { callee } = node;
666
+ const [target] = node.arguments;
667
+ if ((callee.type === "Identifier" ? callee.name === "navigateTo" : callee.type === "MemberExpression" && ROUTER_METHODS.has(nameOf(callee) ?? "") && isRouter(callee.object)) && isHandBuiltPath(target)) report({
668
+ node: target,
669
+ messageId: "path"
670
+ });
671
+ },
672
+ Property: (node) => {
673
+ if (node.key.type === "Identifier" && LINK_NAMES.has(node.key.name) && isHandBuiltPath(node.value)) report({
674
+ node: node.value,
675
+ messageId: "path"
676
+ });
677
+ }
678
+ };
679
+ }
680
+ });
685
681
  var resolve_default = defineRule({
686
682
  meta: {
687
683
  type: "problem",
688
- docs: { description: "Disallow testing the result of linkResolver.resolve(), which is never empty." },
684
+ docs: { description: "Disallow testing the result of linkResolver.resolve(), which falls back to a '#…' string instead of an empty value." },
689
685
  schema: [],
690
- messages: { tested: "linkResolver.resolve() never returns an empty value: a link it can't resolve comes back as a '#missing-required-params…' string, so this check always passes. Use resolveOrThrow() in a try/catch to tell an unresolvable link apart." }
686
+ messages: {
687
+ tested: "linkResolver.resolve() returns a '#…' fallback instead of an empty value when it can't resolve a link, so this check doesn't catch broken links. Use resolveOrThrow() (frontend-core 0.42+) in a try/catch to tell them apart.",
688
+ branch: "When it can't resolve the link, linkResolver.resolve() returns a '#…' fallback here, so the checks on {{name}} treat a broken link as a working one. Use resolveOrThrow() (frontend-core 0.42+) in a try/catch and return undefined instead."
689
+ }
691
690
  },
692
691
  applies: (file) => file.side === "app",
693
692
  create: ({ report, visitTemplate }) => {
694
- const resolveNames = /* @__PURE__ */ new Set();
693
+ const destructured = /* @__PURE__ */ new Map();
695
694
  const calls = [];
696
695
  const scriptIdentifiers = [];
697
696
  const templateIdentifiers = [];
@@ -700,8 +699,7 @@ var resolve_default = defineRule({
700
699
  } });
701
700
  return {
702
701
  "VariableDeclarator": (node) => {
703
- if (node.id.type !== "ObjectPattern" || !node.init || nameOf(node.init) !== "linkResolver") return;
704
- for (const property of node.id.properties) if (property.type === "Property" && nameOf(property.key) === "resolve" && property.value.type === "Identifier") resolveNames.add(property.value.name);
702
+ destructuredResolvers(node).forEach((method, local) => destructured.set(local, method));
705
703
  },
706
704
  "CallExpression": (node) => {
707
705
  calls.push(node);
@@ -711,26 +709,38 @@ var resolve_default = defineRule({
711
709
  },
712
710
  "Program:exit": () => {
713
711
  const holders = /* @__PURE__ */ new Map();
712
+ const reported = /* @__PURE__ */ new Set();
714
713
  for (const call of calls) {
715
- const { callee } = call;
716
- if (!(callee.type === "MemberExpression" && nameOf(callee) === "resolve" && nameOf(callee.object) === "linkResolver") && !(callee.type === "Identifier" && resolveNames.has(callee.name))) continue;
714
+ if (linkResolverMethod(call, destructured) !== "resolve") continue;
717
715
  if (isTested(call)) report({
718
716
  node: call,
719
717
  messageId: "tested"
720
718
  });
721
719
  const holder = findHolder(call);
722
- if (holder) holders.set(holder.name, holder.computed);
720
+ if (holder) holders.set(holder.name, {
721
+ computed: holder.computed,
722
+ branch: holder.fn && canReturnNullish(holder.fn, call) ? call : void 0
723
+ });
723
724
  }
724
725
  const check = (identifier, template) => {
725
- const computed = holders.get(identifier.name);
726
+ const holder = holders.get(identifier.name);
726
727
  const { parent } = identifier;
727
- if (computed === void 0 || !isReference(identifier)) return;
728
+ if (!holder || !isReference(identifier)) return;
728
729
  const readsValue = parent.type === "MemberExpression" && parent.object === identifier && nameOf(parent) === "value";
729
- const value = computed && !template ? readsValue ? parent : void 0 : identifier;
730
- if (value && isTested(value)) report({
730
+ const value = holder.computed && !template ? readsValue ? parent : void 0 : identifier;
731
+ if (!value || !isTested(value)) return;
732
+ if (!holder.branch) report({
731
733
  node: value,
732
734
  messageId: "tested"
733
735
  });
736
+ else if (!reported.has(holder.branch)) {
737
+ reported.add(holder.branch);
738
+ report({
739
+ node: holder.branch,
740
+ messageId: "branch",
741
+ data: { name: identifier.name }
742
+ });
743
+ }
734
744
  };
735
745
  scriptIdentifiers.forEach((identifier) => check(identifier, false));
736
746
  templateIdentifiers.forEach((identifier) => check(identifier, true));
@@ -741,24 +751,27 @@ var resolve_default = defineRule({
741
751
  function kebabCase(name) {
742
752
  return name.replace(/([a-z\d])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
743
753
  }
744
- function prefixName(name, prefix) {
745
- return `${prefix}${name.endsWith(prefix) ? name.slice(0, -prefix.length) : name}`;
746
- }
747
754
  function camelCase(name) {
748
755
  return name.replace(/[-_\s]+(.)?/g, (_, char) => char?.toUpperCase() ?? "").replace(/^[A-Z]/, (char) => char.toLowerCase());
749
756
  }
750
- const UI_KIT_BUTTON = /(?:^#ui-kit|@laioutr-core\/ui-kit)\/.*\/Button\.vue$/;
757
+ const UI_KIT_BUTTON = /(?:^#ui-kit|@laioutr-core\/ui-kit)\/.*\/(?:Icon|BackgroundAware|Animated)?Button\.vue$/;
758
+ const BUTTONS = [
759
+ "LButton",
760
+ "LIconButton",
761
+ "LBackgroundAwareButton",
762
+ "LAnimatedButton"
763
+ ];
751
764
  var button_default = defineRule({
752
765
  meta: {
753
766
  type: "problem",
754
767
  fixable: "code",
755
- docs: { description: "Disallow `type` on the ui-kit button, which silently renders its `button-type` prop instead." },
768
+ docs: { description: "Disallow `type` on the ui-kit buttons, which silently render their `button-type` prop instead." },
756
769
  schema: [],
757
- messages: { buttonType: "l-button ignores type and always renders its button-type prop, which defaults to \"button\". Use button-type." }
770
+ messages: { buttonType: "<{{tag}}> ignores type and always renders its button-type prop, which defaults to \"button\". Use button-type." }
758
771
  },
759
772
  applies: (file) => file.path.endsWith(".vue"),
760
773
  create: ({ report, visitTemplate }) => {
761
- const buttons = /* @__PURE__ */ new Set(["LButton", "l-button"]);
774
+ const buttons = new Set(BUTTONS.flatMap((name) => [name, kebabCase(name)]));
762
775
  visitTemplate({ VElement: (node) => {
763
776
  if (!buttons.has(node.rawName)) return;
764
777
  for (const attribute of node.startTag.attributes) {
@@ -767,6 +780,7 @@ var button_default = defineRule({
767
780
  report({
768
781
  loc: key.loc,
769
782
  messageId: "buttonType",
783
+ data: { tag: node.rawName },
770
784
  fix: (fixer) => fixer.replaceTextRange(key.range, "button-type")
771
785
  });
772
786
  }
@@ -774,6 +788,7 @@ var button_default = defineRule({
774
788
  if (attribute.directive && attribute.key.name.name === "bind" && argument?.type === "VIdentifier" && argument.rawName === "type") report({
775
789
  loc: argument.loc,
776
790
  messageId: "buttonType",
791
+ data: { tag: node.rawName },
777
792
  fix: (fixer) => fixer.replaceTextRange(argument.range, "button-type")
778
793
  });
779
794
  }
@@ -788,101 +803,6 @@ var button_default = defineRule({
788
803
  } };
789
804
  }
790
805
  });
791
- function nuxtName(folders, stem) {
792
- const parts = [...folders];
793
- while (parts.length > 0 && kebabCase(stem).startsWith(kebabCase(parts.at(-1)))) parts.pop();
794
- return [...parts, stem].join("");
795
- }
796
- const PACKAGES = ["@laioutr-core/ui-kit", "@laioutr-core/ui"];
797
- const cache$1 = /* @__PURE__ */ new Map();
798
- function uiKitTags(root) {
799
- let tags = cache$1.get(root);
800
- if (!tags) {
801
- tags = /* @__PURE__ */ new Set();
802
- for (const name of PACKAGES) {
803
- const folder = findPackage(root, name);
804
- if (!folder) throw new Error(`larrylint can't find ${name} from ${root}, which it reads the <l-*> components from. Install the app's dependencies first.`);
805
- for (const file of readdirSync(join(folder, "dist/runtime"), {
806
- recursive: true,
807
- encoding: "utf8"
808
- })) {
809
- if (!file.endsWith(".vue")) continue;
810
- const { dir, name: stem } = parse(file);
811
- const segments = dir.split("/");
812
- const folders = segments.slice(segments.lastIndexOf("components") + 1);
813
- tags.add(`l-${kebabCase(stem)}`);
814
- tags.add(`l-${kebabCase(nuxtName(folders, stem))}`);
815
- }
816
- }
817
- cache$1.set(root, tags);
818
- }
819
- return tags;
820
- }
821
- var tags_default = defineRule({
822
- meta: {
823
- type: "problem",
824
- docs: { description: "Require `<l-*>` tags to be ui-kit or ui components." },
825
- schema: [],
826
- messages: { unknown: "<{{tag}}> isn't a ui-kit or ui component, so it renders nothing. Only their components have the l- prefix; use your own components by their own name." }
827
- },
828
- applies: (file) => file.path.endsWith(".vue"),
829
- create: ({ file, report, visitTemplate }) => {
830
- const imported = /* @__PURE__ */ new Set();
831
- visitTemplate({ VElement: (node) => {
832
- const tag = kebabCase(node.rawName);
833
- if (tag.startsWith("l-") && !imported.has(tag) && !uiKitTags(file.root).has(tag)) report({
834
- loc: node.startTag.loc,
835
- messageId: "unknown",
836
- data: { tag: node.rawName }
837
- });
838
- } });
839
- return { ImportDeclaration: (node) => {
840
- for (const specifier of node.specifiers) imported.add(kebabCase(specifier.local.name));
841
- } };
842
- }
843
- });
844
- const COOKIE_SETTERS = /* @__PURE__ */ new Set(["setCookie", "deleteCookie"]);
845
- const REDIRECTS = /* @__PURE__ */ new Set(["sendRedirect", "navigateTo"]);
846
- const HEADER_SETTERS = /* @__PURE__ */ new Set([
847
- "setHeader",
848
- "setHeaders",
849
- "appendHeader",
850
- "setResponseHeader",
851
- "setResponseHeaders",
852
- "appendResponseHeader"
853
- ]);
854
- function responseEffect(call) {
855
- const name = nameOf(call.callee) ?? "";
856
- if (COOKIE_SETTERS.has(name)) return "cookie";
857
- if (REDIRECTS.has(name)) return "redirect";
858
- const header = HEADER_SETTERS.has(name) ? call.arguments.find((argument) => argument.type === "Literal" && typeof argument.value === "string") : void 0;
859
- const value = header?.type === "Literal" ? String(header.value).toLowerCase() : void 0;
860
- return value === "set-cookie" ? "cookie" : value === "location" ? "redirect" : void 0;
861
- }
862
- function thrownStatus(error) {
863
- if (error.type !== "CallExpression" && error.type !== "NewExpression") return;
864
- for (const argument of error.arguments) {
865
- const status = argument.type === "ObjectExpression" ? findProperty(argument, "status") ?? findProperty(argument, "statusCode") : void 0;
866
- if (status?.type === "Literal" && typeof status.value === "number") return status.value;
867
- }
868
- }
869
- var errors_default = defineRule({
870
- meta: {
871
- type: "problem",
872
- docs: { description: "Require actions to return client errors as a status instead of throwing them." },
873
- schema: [],
874
- messages: { status: "The client never sees this {{status}}: orchestr's action transport drops status codes, so it arrives as a failed action. Return it as a status in the action's output instead." }
875
- },
876
- applies: (file) => file.handler === "action",
877
- create: ({ report }) => ({ ThrowStatement: (node) => {
878
- const status = thrownStatus(node.argument);
879
- if (status !== void 0 && status >= 400 && status < 500) report({
880
- node,
881
- messageId: "status",
882
- data: { status: String(status) }
883
- });
884
- } })
885
- });
886
806
  function readMiddleware(call) {
887
807
  const { callee } = call;
888
808
  if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return;
@@ -894,42 +814,78 @@ function readMiddleware(call) {
894
814
  callback
895
815
  } : void 0;
896
816
  }
897
- function readMetaApp(call) {
898
- const { callee } = call;
899
- const [options] = call.arguments;
900
- if (callee.type !== "MemberExpression" || callee.property.type !== "Identifier" || callee.property.name !== "meta" || chainRoot(callee.object)?.name !== "defineOrchestr" || options?.type !== "ObjectExpression") return;
901
- return findStringProperty(options, "app");
902
- }
903
- function inMiddleware(node) {
904
- for (let parent = node.parent; parent; parent = parent.parent) if (isFunction(parent) && parent.parent?.type === "CallExpression" && readMiddleware(parent.parent)?.callback === parent) return true;
905
- return false;
906
- }
907
- function componentObjects(value) {
908
- if (value.type === "ObjectExpression") return [value];
909
- if (!isFunction(value)) return [];
910
- if (value.body.type === "ObjectExpression") return [value.body];
911
- const objects = [];
912
- walkBody(value, (node) => {
913
- if (node.type === "ReturnStatement" && node.argument?.type === "ObjectExpression") objects.push(node.argument);
914
- });
915
- return objects;
916
- }
817
+ function enclosingMiddleware(node) {
818
+ for (let parent = node.parent; parent; parent = parent.parent) {
819
+ const middleware = isFunction(parent) && parent.parent?.type === "CallExpression" ? readMiddleware(parent.parent) : void 0;
820
+ if (middleware?.callback === parent) return middleware;
821
+ }
822
+ }
823
+ const COOKIE_WRITES = /* @__PURE__ */ new Set([
824
+ "setCookie",
825
+ "deleteCookie",
826
+ "setManagedCookie",
827
+ "deleteManagedCookie"
828
+ ]);
829
+ const HEADER_WRITES = /* @__PURE__ */ new Set([
830
+ "setHeader",
831
+ "setHeaders",
832
+ "appendHeader",
833
+ "appendHeaders",
834
+ "setResponseHeader",
835
+ "setResponseHeaders",
836
+ "appendResponseHeader",
837
+ "appendResponseHeaders",
838
+ "setResponseStatus",
839
+ "sendRedirect"
840
+ ]);
841
+ function responseWrite(call) {
842
+ const name = call.callee.type === "Identifier" ? call.callee.name : "";
843
+ if (COOKIE_WRITES.has(name)) return {
844
+ name,
845
+ kind: "cookie"
846
+ };
847
+ return HEADER_WRITES.has(name) ? {
848
+ name,
849
+ kind: "header"
850
+ } : void 0;
851
+ }
852
+ const STREAMED = /* @__PURE__ */ new Set([
853
+ "query",
854
+ "link",
855
+ "resolver"
856
+ ]);
857
+ const MANAGED = {
858
+ setCookie: "setManagedCookie",
859
+ deleteCookie: "deleteManagedCookie"
860
+ };
917
861
  var cookies_default = defineRule({
918
862
  meta: {
919
863
  type: "problem",
920
- docs: { description: "Disallow Set-Cookie and redirects from orchestr handlers and middleware, which cached pages replay to every visitor." },
864
+ docs: { description: "Write cookies and headers only where orchestr allows it: in extendRequest() and action handlers, with frontend-core's managed cookie functions." },
921
865
  schema: [],
922
866
  messages: {
923
- cookie: "ISR caches this response for every visitor, Set-Cookie included, so one visitor's cookie reaches the next. Set cookies from an API route or a Nitro middleware that skips cached pages.",
924
- redirect: "ISR caches this redirect for every visitor. Redirect from an API route or on the client instead."
867
+ streamed: "Orchestr may have sent the response headers by the time {{name}}() runs here, so the browser never gets it. Write cookies and headers in extendRequest() or an action handler.",
868
+ managed: "Use {{managed}}() instead. It applies Laioutr's cookie policy, which the Studio preview needs (SameSite=None and Partitioned), and {{name}}() skips it."
925
869
  }
926
870
  },
927
871
  applies: (file) => file.side === "server",
928
872
  create: ({ file, report }) => ({ CallExpression: (node) => {
929
- const effect = responseEffect(node);
930
- if (effect && (file.kind === "handler" || inMiddleware(node))) report({
873
+ const write = responseWrite(node);
874
+ if (!write) return;
875
+ const method = enclosingMiddleware(node)?.method;
876
+ const managed = MANAGED[write.name];
877
+ if (method === "use" || !method && STREAMED.has(file.handler)) report({
931
878
  node,
932
- messageId: effect
879
+ messageId: "streamed",
880
+ data: { name: write.name }
881
+ });
882
+ else if (managed && (method === "extendRequest" || !method && file.handler === "action")) report({
883
+ node,
884
+ messageId: "managed",
885
+ data: {
886
+ name: write.name,
887
+ managed
888
+ }
933
889
  });
934
890
  } })
935
891
  });
@@ -946,9 +902,9 @@ const FILE_START = {
946
902
  var files_default$1 = defineRule({
947
903
  meta: {
948
904
  type: "problem",
949
- docs: { description: "Keep orchestr/ to handler files, since Laioutr loads every file in it as a server plugin." },
905
+ docs: { description: "Keep orchestr/ to handler files, since Laioutr loads every file in it as a Nitro plugin." },
950
906
  schema: [],
951
- messages: { notAHandler: "Laioutr loads every file in orchestr/ as a server plugin. Name handlers *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts or *.page-index.ts, and move everything else to server/utils/." }
907
+ messages: { notAHandler: "Laioutr loads every file in orchestr/ as a Nitro plugin, so a helper here runs at startup or breaks the build. Move it to server/utils/, and name handlers *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts or *.page-index.ts." }
952
908
  },
953
909
  applies: (file) => file.kind === "orchestr-file",
954
910
  create: ({ report }) => ({ Program: () => {
@@ -958,54 +914,12 @@ var files_default$1 = defineRule({
958
914
  });
959
915
  } })
960
916
  });
961
- var context_default = defineRule({
962
- meta: {
963
- type: "suggestion",
964
- docs: { description: "Require handlers to take config, clients and the request from their arguments and middleware context." },
965
- schema: [],
966
- messages: {
967
- runtimeConfig: "Handlers get their config and clients from the middleware context. Read the config once in the builder's middleware.",
968
- event: "Handlers get the request as `event` in their arguments, not from useEvent()."
969
- }
970
- },
971
- applies: (file) => file.kind === "handler",
972
- create: ({ report }) => ({ CallExpression: (node) => {
973
- if (node.callee.type !== "Identifier") return;
974
- if (node.callee.name === "useRuntimeConfig") report({
975
- node,
976
- messageId: "runtimeConfig"
977
- });
978
- else if (node.callee.name === "useEvent") report({
979
- node,
980
- messageId: "event"
981
- });
982
- } })
983
- });
984
- var domains_default = defineRule({
985
- meta: {
986
- type: "problem",
987
- docs: { description: "Keep orchestr handlers in domain folders." },
988
- schema: [],
989
- messages: { noDomain: "Put handlers in a domain folder, e.g. orchestr/<domain>/{{file}}." }
990
- },
991
- applies: (file) => file.kind === "handler" && !file.domain,
992
- create: ({ file, report }) => ({ Program: () => {
993
- report({
994
- loc: FILE_START,
995
- messageId: "noDomain",
996
- data: { file: basename(file.path) }
997
- });
998
- } })
999
- });
1000
917
  var exports_default = defineRule({
1001
918
  meta: {
1002
919
  type: "problem",
1003
- docs: { description: "Require handler files to export their handler as default, and nothing else." },
920
+ docs: { description: "Require handler files to export their handler as default." },
1004
921
  schema: [],
1005
- messages: {
1006
- namedExport: "Handler files only export their handler. Laioutr registers the default export; move everything else to server/utils/.",
1007
- missingDefault: "Handler files export their handler as default, otherwise laioutr registers nothing."
1008
- }
922
+ messages: { missingDefault: "Laioutr loads every file in orchestr/ as a Nitro plugin from its default export, so without one the build fails. Export the handler as default." }
1009
923
  },
1010
924
  applies: (file) => file.kind === "handler",
1011
925
  create: ({ report }) => {
@@ -1014,22 +928,8 @@ var exports_default = defineRule({
1014
928
  "ExportDefaultDeclaration": () => {
1015
929
  hasDefault = true;
1016
930
  },
1017
- "ExportNamedDeclaration": (node) => {
1018
- const others = node.specifiers.filter((specifier) => {
1019
- const name = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value;
1020
- if (name === "default") hasDefault = true;
1021
- return name !== "default";
1022
- });
1023
- if (node.declaration || others.length > 0) report({
1024
- node,
1025
- messageId: "namedExport"
1026
- });
1027
- },
1028
- "ExportAllDeclaration": (node) => {
1029
- report({
1030
- node,
1031
- messageId: "namedExport"
1032
- });
931
+ "ExportSpecifier": (node) => {
932
+ hasDefault ||= (node.exported.type === "Identifier" ? node.exported.name : node.exported.value) === "default";
1033
933
  },
1034
934
  "Program:exit": () => {
1035
935
  if (!hasDefault) report({
@@ -1073,42 +973,6 @@ var files_default = defineRule({
1073
973
  };
1074
974
  }
1075
975
  });
1076
- const readPackageFile = cachedReader((text) => {
1077
- const json = JSON.parse(text);
1078
- return {
1079
- name: json.name,
1080
- dependencies: new Set([
1081
- json.dependencies,
1082
- json.devDependencies,
1083
- json.peerDependencies,
1084
- json.optionalDependencies
1085
- ].flatMap((deps) => Object.keys(deps ?? {})))
1086
- };
1087
- });
1088
- function readPackage(root) {
1089
- return readPackageFile(join(root, "package.json"));
1090
- }
1091
- var meta_default = defineRule({
1092
- meta: {
1093
- type: "problem",
1094
- docs: { description: "Require `meta({ app })` of an orchestr builder to be the package name." },
1095
- schema: [],
1096
- messages: { app: "meta({ app }) is '{{actual}}', but the package is '{{expected}}'. Traces use this name to tell which app's middleware ran." }
1097
- },
1098
- applies: (file) => file.side === "server",
1099
- create: ({ file, report }) => ({ CallExpression: (node) => {
1100
- const app = readMetaApp(node);
1101
- const expected = app && readPackage(file.root)?.name;
1102
- if (app && expected && app.value !== expected) report({
1103
- node: app.node,
1104
- messageId: "app",
1105
- data: {
1106
- actual: app.value,
1107
- expected
1108
- }
1109
- });
1110
- } })
1111
- });
1112
976
  var throws_default = defineRule({
1113
977
  meta: {
1114
978
  type: "problem",
@@ -1149,42 +1013,6 @@ var throws_default = defineRule({
1149
1013
  });
1150
1014
  } })
1151
1015
  });
1152
- var passthrough_default = defineRule({
1153
- meta: {
1154
- type: "problem",
1155
- docs: { description: "Disallow passthrough.require() in component resolvers." },
1156
- schema: [],
1157
- messages: { require: "Other queries for this entity may not set this passthrough, and require() then fails the whole resolver. Use passthrough.get() and handle a missing value." }
1158
- },
1159
- applies: (file) => file.handler === "resolver",
1160
- create: ({ report }) => ({ CallExpression: (node) => {
1161
- const { callee } = node;
1162
- if (callee.type === "MemberExpression" && nameOf(callee) === "require" && nameOf(callee.object) === "passthrough") report({
1163
- node: callee.property,
1164
- messageId: "require"
1165
- });
1166
- } })
1167
- });
1168
- var returns_default = defineRule({
1169
- meta: {
1170
- type: "problem",
1171
- docs: { description: "Require component resolvers to resolve fields to empty values rather than undefined or null." },
1172
- schema: [],
1173
- messages: { empty: "Studio shows a literal \"...\" for a field that resolves to undefined or null. Resolve to an empty value instead, e.g. ''." }
1174
- },
1175
- applies: (file) => file.handler === "resolver",
1176
- create: ({ report }) => ({ CallExpression: (node) => {
1177
- const [entity] = node.arguments;
1178
- if (node.callee.type !== "Identifier" || node.callee.name !== "$entity" || entity?.type !== "ObjectExpression") return;
1179
- for (const component of entity.properties) {
1180
- if (component.type !== "Property" || component.key.type === "Identifier" && component.key.name === "id") continue;
1181
- for (const object of componentObjects(component.value)) for (const field of object.properties) if (field.type === "Property" && canBeNullish(field.value)) report({
1182
- node: field.value,
1183
- messageId: "empty"
1184
- });
1185
- }
1186
- } })
1187
- });
1188
1016
  const PACKAGE = "@laioutr-core/canonical-types";
1189
1017
  const TOKEN_ID = /define\w*Token\(\s*["'`]([a-z0-9-]+)\//g;
1190
1018
  const cache = /* @__PURE__ */ new Map();
@@ -1208,7 +1036,7 @@ var namespaces_default = defineRule({
1208
1036
  type: "problem",
1209
1037
  docs: { description: "Keep your own token ids out of the namespaces of Laioutr's canonical types." },
1210
1038
  schema: [],
1211
- messages: { canonical: "'{{namespace}}/' belongs to Laioutr's canonical types. Put your own tokens under your app's namespace; otherwise registration order decides which handler wins." }
1039
+ messages: { canonical: "'{{namespace}}/' belongs to Laioutr's canonical types, and a later canonical-types release or another app can take this id. Token metadata is last-write-wins, so put your own tokens under your app's namespace." }
1212
1040
  },
1213
1041
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
1214
1042
  create: ({ file, report }) => ({ CallExpression: (node) => {
@@ -1222,46 +1050,40 @@ var namespaces_default = defineRule({
1222
1050
  });
1223
1051
  } })
1224
1052
  });
1225
- function objectShape(schema) {
1226
- let current = schema;
1227
- while (current?.type === "CallExpression" && current.callee.type === "MemberExpression" && current.callee.property.type === "Identifier") {
1228
- const [shape] = current.arguments;
1229
- if (current.callee.property.name === "object" && shape?.type === "ObjectExpression") return shape;
1230
- current = current.callee.object;
1231
- }
1053
+ function findProperty(object, key) {
1054
+ for (const entry of object.properties) if (entry.type === "Property" && !entry.computed && (entry.key.type === "Identifier" && entry.key.name === key || entry.key.type === "Literal" && entry.key.value === key)) return entry.value;
1055
+ }
1056
+ function findStringProperty(object, key) {
1057
+ const value = findProperty(object, key);
1058
+ return value?.type === "Literal" && typeof value.value === "string" ? {
1059
+ value: value.value,
1060
+ node: value
1061
+ } : void 0;
1232
1062
  }
1233
- function isNullable(schema) {
1234
- return chainMethods(schema).some((method) => method === "nullable" || method === "nullish");
1063
+ function objectElements(node) {
1064
+ if (node?.type !== "ArrayExpression") return [];
1065
+ return node.elements.filter((element) => element?.type === "ObjectExpression");
1235
1066
  }
1236
- var nullable_default = defineRule({
1237
- meta: {
1238
- type: "problem",
1239
- docs: { description: "Disallow nullable top-level fields in entity component tokens, which Studio can't bind." },
1240
- schema: [],
1241
- messages: { nullable: "Studio can't bind a nullable field. Resolve it to an empty value instead, e.g. '' or []." }
1242
- },
1243
- applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
1244
- create: ({ report }) => ({ CallExpression: (node) => {
1245
- const options = node.arguments[1];
1246
- if (node.callee.type !== "Identifier" || node.callee.name !== "defineEntityComponentToken" || options?.type !== "ObjectExpression") return;
1247
- for (const field of objectShape(findProperty(options, "schema"))?.properties ?? []) if (field.type === "Property" && isNullable(field.value)) report({
1248
- node: field,
1249
- messageId: "nullable"
1250
- });
1251
- } })
1252
- });
1253
1067
  const DEFINERS = {
1254
1068
  defineSection: {
1255
1069
  kind: "section",
1256
- folder: "sections",
1257
- prefix: "Section"
1070
+ folder: "sections"
1258
1071
  },
1259
1072
  defineBlock: {
1260
1073
  kind: "block",
1261
- folder: "blocks",
1262
- prefix: "Block"
1074
+ folder: "blocks"
1263
1075
  }
1264
1076
  };
1077
+ const PICKERS = /* @__PURE__ */ new Set([
1078
+ "select",
1079
+ "radio",
1080
+ "toggle_button"
1081
+ ]);
1082
+ const TEXTS$1 = /* @__PURE__ */ new Set([
1083
+ "text",
1084
+ "textarea",
1085
+ "secret"
1086
+ ]);
1265
1087
  function readDefinition(call) {
1266
1088
  if (call.callee.type !== "Identifier" || !Object.hasOwn(DEFINERS, call.callee.name)) return;
1267
1089
  const [options] = call.arguments;
@@ -1277,7 +1099,6 @@ function readGroups(owner) {
1277
1099
  const node = "call" in owner ? owner.options : owner.node;
1278
1100
  return objectElements(node && findProperty(node, "schema")).map((group) => ({
1279
1101
  node: group,
1280
- label: findStringProperty(group, "label"),
1281
1102
  fields: objectElements(findProperty(group, "fields")).map(readField)
1282
1103
  }));
1283
1104
  }
@@ -1301,15 +1122,42 @@ function readAllFields(definition) {
1301
1122
  readFields(definition).forEach(visit);
1302
1123
  return fields;
1303
1124
  }
1125
+ function conditionFields(definition) {
1126
+ const names = /* @__PURE__ */ new Set();
1127
+ for (const field of readAllFields(definition)) {
1128
+ const condition = findProperty(field.node, "if");
1129
+ if (condition) walk(condition, (node) => {
1130
+ const [operator, name] = node.type === "ArrayExpression" ? node.elements : [];
1131
+ if (operator?.type === "Literal" && operator.value === "get" && name?.type === "Literal" && typeof name.value === "string") names.add(name.value.split(".")[0]);
1132
+ });
1133
+ }
1134
+ return names;
1135
+ }
1304
1136
  function expectedDefiner(kind) {
1305
1137
  return Object.keys(DEFINERS).find((definer) => DEFINERS[definer].kind === kind);
1306
1138
  }
1139
+ function fillValue(field, program, filename) {
1140
+ if (field.type === "checkbox") return { value: field.as === "visibility" };
1141
+ if (TEXTS$1.has(field.type ?? "")) return { value: "" };
1142
+ if (field.type === "content_alignment") {
1143
+ const axis = findStringProperty(field.node, "axis")?.value;
1144
+ return { value: !axis || axis === "both" ? "center-center" : "center" };
1145
+ }
1146
+ if (!PICKERS.has(field.type ?? "")) return;
1147
+ const property = findProperty(field.node, "options");
1148
+ const declared = property?.type === "Identifier" ? resolveConstant(property.name, program, filename) : property;
1149
+ let options = declared && unwrap(declared);
1150
+ if (options?.type === "CallExpression") options = options.arguments[0];
1151
+ const first = options?.type === "ArrayExpression" ? options.elements[0] : void 0;
1152
+ const value = first?.type === "ObjectExpression" ? findProperty(first, "value") : first;
1153
+ return value?.type === "Literal" ? { value: value.value } : void 0;
1154
+ }
1307
1155
  var description_default = defineRule({
1308
1156
  meta: {
1309
1157
  type: "suggestion",
1310
1158
  docs: { description: "Require a studio.description on section and block definitions." },
1311
1159
  schema: [],
1312
- messages: { description: "Studio shows studio.description in its section and block picker. Add one." }
1160
+ messages: { description: "Studio shows studio.description in its section picker, and AI agents read it through Laioutr's MCP server. Add one." }
1313
1161
  },
1314
1162
  applies: (file) => file.side === "app",
1315
1163
  create: ({ report }) => ({ CallExpression: (node) => {
@@ -1330,7 +1178,7 @@ var folder_default = defineRule({
1330
1178
  docs: { description: "Keep defineSection() in app/sections/ and defineBlock() in app/blocks/, and every .vue there defined." },
1331
1179
  schema: [],
1332
1180
  messages: {
1333
- notDefined: "Laioutr registers every .vue in {{folder}}/ as a Studio {{kind}}, but this file has no {{definer}}(). Move it to components/.",
1181
+ notDefined: "Laioutr loads every .vue in {{folder}}/ as a {{kind}}, but this file has no {{definer}}(), so frontend-core warns about it. Move it to components/.",
1334
1182
  wrongFolder: "{{definer}}() belongs in app/{{folder}}/."
1335
1183
  }
1336
1184
  },
@@ -1391,118 +1239,100 @@ var name_default = defineRule({
1391
1239
  });
1392
1240
  } })
1393
1241
  });
1394
- var prefix_default = defineRule({
1395
- meta: {
1396
- type: "suggestion",
1397
- docs: { description: "Name sections Section*.vue and blocks Block*.vue." },
1398
- schema: [],
1399
- messages: { prefix: "Name {{kind}}s {{prefix}}*.vue, e.g. {{suggestion}}.vue." }
1400
- },
1401
- applies: (file) => file.kind === "section" || file.kind === "block",
1402
- create: ({ file, report }) => ({ CallExpression: (node) => {
1403
- const definition = readDefinition(node);
1404
- if (!definition) return;
1405
- const { kind, prefix } = DEFINERS[definition.definer];
1406
- const { name } = parse(file.path);
1407
- if (file.kind === kind && !name.startsWith(prefix)) report({
1408
- node: node.callee,
1409
- messageId: "prefix",
1410
- data: {
1411
- kind,
1412
- prefix,
1413
- suggestion: prefixName(name, prefix)
1414
- }
1415
- });
1416
- } })
1417
- });
1418
- const FALSE_WHEN_UNSET = /* @__PURE__ */ new Set([
1419
- "checkbox",
1420
- "select",
1421
- "radio",
1422
- "toggle_button"
1242
+ const TEXTS = /* @__PURE__ */ new Set([
1243
+ "text",
1244
+ "textarea",
1245
+ "secret"
1423
1246
  ]);
1424
- const EMPTY_WHEN_UNSET = /* @__PURE__ */ new Set(["text", "textarea"]);
1425
1247
  var fallbacks_default = defineRule({
1426
1248
  meta: {
1427
1249
  type: "problem",
1428
- docs: { description: "Disallow fallbacks for props that never arrive as undefined: unset picker fields arrive as false, unset text fields as ''." },
1250
+ docs: { description: "Disallow ?? fallbacks that never apply, because frontend-core fills unset fields: pickers with their first option, checkboxes with false and text with ''." },
1429
1251
  schema: [],
1430
1252
  messages: {
1431
- checkbox: "An unset checkbox arrives as false, not undefined, so this {{check}} never sees a missing value. Name the field so that unchecked is the default.",
1432
- picker: "An unset {{type}} field arrives as false, not undefined, so this {{check}} never sees a missing value. Use || for a fallback.",
1433
- text: "Studio passes '' for an unset {{type}} field, so ?? never falls back. Use ||."
1253
+ picker: "frontend-core fills an unset {{type}} field with its first option, {{fill}}, so this fallback never applies. Make {{fallback}} the first option if it should be the default.",
1254
+ checkbox: "frontend-core fills an unset checkbox with false, so this fallback never applies. Name the field so that unchecked is the default.",
1255
+ text: "frontend-core fills an unset {{type}} field with '', so ?? never falls back. Use || if an empty field should fall back."
1434
1256
  }
1435
1257
  },
1436
1258
  applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
1437
1259
  create: ({ context, report, visitTemplate }) => {
1438
- const types = /* @__PURE__ */ new Map();
1439
- const propsVariable = findPropsVariable(context.sourceCode.ast);
1440
- const reads = [];
1260
+ const program = context.sourceCode.ast;
1261
+ const propsVariable = findPropsVariable(program);
1262
+ const fills = /* @__PURE__ */ new Map();
1263
+ const fallbacks = [];
1441
1264
  const propOf = (node, template) => {
1442
1265
  if (template && node.type === "Identifier") return node.name;
1443
1266
  const props = node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && node.object.type === "Identifier" ? node.object.name : void 0;
1444
1267
  return props && (props === propsVariable || template && props === "$props") && node.type === "MemberExpression" && node.property.type === "Identifier" ? node.property.name : void 0;
1445
1268
  };
1446
- const collect = (template) => ({
1447
- LogicalExpression: (node) => {
1448
- const prop = node.operator === "??" ? propOf(node.left, template) : void 0;
1449
- if (prop) reads.push({
1450
- node,
1451
- prop,
1452
- check: "??"
1453
- });
1454
- },
1455
- BinaryExpression: (node) => {
1456
- if (node.operator !== "!==" && node.operator !== "===" && node.operator !== "!=" && node.operator !== "==") return;
1457
- for (const [side, other] of [[node.left, node.right], [node.right, node.left]]) {
1458
- const prop = propOf(side, template);
1459
- const value = other.type === "Identifier" && other.name === "undefined" ? "undefined" : other.type === "Literal" && other.value === null && !("regex" in other) ? "null" : other.type === "Literal" && other.value === false && node.operator === "!==" ? "false" : void 0;
1460
- if (prop && value) reads.push({
1461
- node,
1462
- prop,
1463
- check: `${node.operator} ${value}`
1464
- });
1465
- }
1466
- }
1467
- });
1269
+ const collect = (template) => ({ LogicalExpression: (node) => {
1270
+ const prop = node.operator === "??" ? propOf(node.left, template) : void 0;
1271
+ if (prop && !isNullish(node.right)) fallbacks.push({
1272
+ node,
1273
+ prop
1274
+ });
1275
+ } });
1468
1276
  visitTemplate(collect(true));
1469
1277
  return {
1470
1278
  ...collect(false),
1471
1279
  "CallExpression": (node) => {
1472
1280
  const definition = readDefinition(node);
1473
- for (const { name, type, as } of definition ? readFields(definition) : []) if (name && type && !as) types.set(name.value, type);
1281
+ for (const field of definition ? readFields(definition) : []) {
1282
+ const fill = field.name && field.type && !field.as ? fillValue(field, program, context.filename) : void 0;
1283
+ if (fill) fills.set(field.name.value, {
1284
+ type: field.type,
1285
+ value: fill.value
1286
+ });
1287
+ }
1474
1288
  },
1475
1289
  "Program:exit": () => {
1476
- for (const { node, prop, check } of reads) {
1477
- const type = types.get(prop);
1478
- if (type === "checkbox") report({
1479
- node,
1480
- messageId: "checkbox",
1481
- data: { check }
1482
- });
1483
- else if (type && FALSE_WHEN_UNSET.has(type)) report({
1290
+ for (const { node, prop } of fallbacks) {
1291
+ const field = fills.get(prop);
1292
+ if (!field || node.right.type === "Literal" && node.right.value === field.value) continue;
1293
+ const messageId = field.type === "checkbox" ? "checkbox" : TEXTS.has(field.type) ? "text" : "picker";
1294
+ const fill = typeof field.value === "string" ? `'${field.value}'` : String(field.value);
1295
+ report({
1484
1296
  node,
1485
- messageId: "picker",
1297
+ messageId,
1486
1298
  data: {
1487
- type,
1488
- check
1299
+ type: field.type,
1300
+ fill,
1301
+ fallback: context.sourceCode.getText(node.right)
1489
1302
  }
1490
1303
  });
1491
- else if (type && EMPTY_WHEN_UNSET.has(type) && check === "??") report({
1492
- node,
1493
- messageId: "text",
1494
- data: { type }
1495
- });
1496
1304
  }
1497
1305
  }
1498
1306
  };
1499
1307
  }
1500
1308
  });
1501
1309
  const PROP_READ = /\.props\??\.([A-Z_$][\w$]*)|\.props\??\.?\[\s*['"]([^'"]+)['"]\s*\]|['"]([^'"]+)['"]\s+in\s+[\w$.?]+\.props\b/gi;
1502
- const readPropNames = cachedReader((text) => [...text.matchAll(PROP_READ)].map((match) => match[1] ?? match[2] ?? match[3]));
1310
+ const PROPS_ALIAS = /\b(?:const|let|var)\s+([\w$]+)\s*=\s*[\w$.?]+\.props\b(?!\s*(?:\?\.|[.[]))/g;
1311
+ const PROPS_DESTRUCTURING = /\{([^{}]*)\}\s*=\s*[\w$.?]+\.props\b(?!\s*(?:\?\.|[.[]))/g;
1312
+ const PROPS_SPREAD = /\.\.\.\s*[\w$.?]+\.props\b(?!\s*(?:\?\.|[.[]))/;
1313
+ const readSlotProps = cachedReader((text) => {
1314
+ const names = [...text.matchAll(PROP_READ)].map((match) => match[1] ?? match[2] ?? match[3]);
1315
+ let all = PROPS_SPREAD.test(text);
1316
+ for (const [, alias] of text.matchAll(PROPS_ALIAS)) {
1317
+ const name = alias.replaceAll("$", "\\$");
1318
+ const reads = new RegExp(`\\b${name}\\??\\.([A-Z_$][\\w$]*)|\\b${name}\\??\\.?\\[\\s*['"]([^'"]+)['"]\\s*\\]`, "gi");
1319
+ for (const match of text.matchAll(reads)) names.push(match[1] ?? match[2]);
1320
+ }
1321
+ for (const [, list] of text.matchAll(PROPS_DESTRUCTURING)) for (const entry of list.split(",").map((item) => item.trim()).filter(Boolean)) {
1322
+ all ||= entry.startsWith("...");
1323
+ names.push(entry.split(/[:=]/)[0].trim());
1324
+ }
1325
+ return {
1326
+ names,
1327
+ all
1328
+ };
1329
+ });
1503
1330
  function slotProps(root) {
1504
1331
  const folder = join(root, "src/runtime/app");
1505
- const names = /* @__PURE__ */ new Set();
1332
+ const result = {
1333
+ names: /* @__PURE__ */ new Set(),
1334
+ all: false
1335
+ };
1506
1336
  let files;
1507
1337
  try {
1508
1338
  files = readdirSync(folder, {
@@ -1510,10 +1340,14 @@ function slotProps(root) {
1510
1340
  encoding: "utf8"
1511
1341
  }).filter((file) => /\.(?:vue|[cm]?[jt]s)$/.test(file));
1512
1342
  } catch {
1513
- return names;
1343
+ return result;
1514
1344
  }
1515
- for (const file of files) readPropNames(join(folder, file))?.forEach((name) => names.add(name));
1516
- return names;
1345
+ for (const file of files) {
1346
+ const read = readSlotProps(join(folder, file));
1347
+ read?.names.forEach((name) => result.names.add(name));
1348
+ result.all ||= read?.all ?? false;
1349
+ }
1350
+ return result;
1517
1351
  }
1518
1352
  const NOT_READ = /* @__PURE__ */ new Set([
1519
1353
  "info",
@@ -1533,6 +1367,7 @@ var unused_default = defineRule({
1533
1367
  const used = /* @__PURE__ */ new Set();
1534
1368
  const propsVariable = findPropsVariable(context.sourceCode.ast);
1535
1369
  let hasProps = false;
1370
+ let block = false;
1536
1371
  let escapes = false;
1537
1372
  const track = (node) => {
1538
1373
  const names = usedProps(node);
@@ -1551,7 +1386,10 @@ var unused_default = defineRule({
1551
1386
  "CallExpression": (node) => {
1552
1387
  hasProps ||= isDefineProps(node);
1553
1388
  const definition = readDefinition(node);
1554
- for (const { name, type, as } of definition ? readFields(definition) : []) if (name && !as && !NOT_READ.has(type ?? "")) fields.set(name.value, name.node);
1389
+ if (!definition) return;
1390
+ block ||= definition.definer === "defineBlock";
1391
+ conditionFields(definition).forEach((name) => used.add(name));
1392
+ for (const { name, type, as } of readFields(definition)) if (name && !as && !NOT_READ.has(type ?? "")) fields.set(name.value, name.node);
1555
1393
  },
1556
1394
  "Identifier": (node) => {
1557
1395
  const declaration = node.parent.type === "VariableDeclarator" && node.parent.id === node;
@@ -1560,8 +1398,9 @@ var unused_default = defineRule({
1560
1398
  "Program:exit": () => {
1561
1399
  if (!hasProps || escapes) return;
1562
1400
  const unused = [...fields].filter(([name]) => !used.has(name));
1563
- const readBySection = unused.length > 0 && file.kind === "block" ? slotProps(file.root) : /* @__PURE__ */ new Set();
1564
- for (const [name, node] of unused) if (!readBySection.has(name)) report({
1401
+ const readBySection = unused.length > 0 && block ? slotProps(file.root) : void 0;
1402
+ if (readBySection?.all) return;
1403
+ for (const [name, node] of unused) if (!readBySection?.names.has(name)) report({
1565
1404
  node,
1566
1405
  messageId: "unused",
1567
1406
  data: { name }
@@ -1570,59 +1409,32 @@ var unused_default = defineRule({
1570
1409
  };
1571
1410
  }
1572
1411
  });
1573
- const CAMEL_CASE = /^[a-z][a-zA-Z0-9]*$/;
1574
1412
  var casing_default = defineRule({
1575
1413
  meta: {
1576
1414
  type: "problem",
1577
- docs: { description: "Require camelCase names for top-level schema fields, which become props." },
1578
- schema: [],
1579
- messages: { casing: "Name the field {{suggestion}}. It becomes a prop, and Vue only passes props by their camelCase name." }
1580
- },
1581
- applies: (file) => file.side === "app",
1582
- create: ({ report }) => ({ CallExpression: (node) => {
1583
- const definition = readDefinition(node);
1584
- for (const { name } of definition ? readFields(definition) : []) if (name && !CAMEL_CASE.test(name.value)) report({
1585
- node: name.node,
1586
- messageId: "casing",
1587
- data: { suggestion: camelCase(name.value) }
1588
- });
1589
- } })
1590
- });
1591
- const PANELS = [
1592
- "Content",
1593
- "Design",
1594
- "Rules"
1595
- ];
1596
- var groups_default = defineRule({
1597
- meta: {
1598
- type: "suggestion",
1599
- docs: { description: "Require schema groups to be Studio's panels, in the order Content, Design, Rules." },
1415
+ docs: { description: "Disallow top-level schema field names that Vue renames or rejects as props." },
1600
1416
  schema: [],
1601
1417
  messages: {
1602
- panel: "Studio's schema has the panels Content, Design and Rules. Move these fields into one of them.",
1603
- order: "'{{label}}' goes before '{{previous}}': the panels are Content, Design, Rules."
1418
+ hyphen: "Vue camelizes '{{name}}' to {{suggestion}}, so the prop never arrives under the field's name. Name the field {{suggestion}}.",
1419
+ dollar: "Vue rejects prop names that start with $, so '{{name}}' never arrives as a prop. Pick another name."
1604
1420
  }
1605
1421
  },
1606
1422
  applies: (file) => file.side === "app",
1607
1423
  create: ({ report }) => ({ CallExpression: (node) => {
1608
1424
  const definition = readDefinition(node);
1609
- let previous;
1610
- for (const { label } of definition ? readGroups(definition) : []) {
1611
- if (!label) continue;
1612
- if (!PANELS.includes(label.value)) report({
1613
- node: label.node,
1614
- messageId: "panel"
1615
- });
1616
- else if (previous && PANELS.indexOf(label.value) < PANELS.indexOf(previous)) report({
1617
- node: label.node,
1618
- messageId: "order",
1619
- data: {
1620
- label: label.value,
1621
- previous
1622
- }
1623
- });
1624
- else previous = label.value;
1625
- }
1425
+ for (const { name } of definition ? readFields(definition) : []) if (name?.value.startsWith("$")) report({
1426
+ node: name.node,
1427
+ messageId: "dollar",
1428
+ data: { name: name.value }
1429
+ });
1430
+ else if (name?.value.includes("-")) report({
1431
+ node: name.node,
1432
+ messageId: "hyphen",
1433
+ data: {
1434
+ name: name.value,
1435
+ suggestion: camelCase(name.value)
1436
+ }
1437
+ });
1626
1438
  } })
1627
1439
  });
1628
1440
  var required_default = defineRule({
@@ -1645,21 +1457,22 @@ var required_default = defineRule({
1645
1457
  } })
1646
1458
  });
1647
1459
  const RESERVED = /* @__PURE__ */ new Set([
1648
- "style",
1649
- "class",
1650
1460
  "key",
1651
1461
  "ref",
1652
- "is",
1653
- "slot",
1654
- "refFor",
1655
- "refKey"
1462
+ "ref_for",
1463
+ "ref_key",
1464
+ "class",
1465
+ "style"
1656
1466
  ]);
1657
1467
  var reserved_default = defineRule({
1658
1468
  meta: {
1659
1469
  type: "problem",
1660
- docs: { description: "Disallow top-level schema field names that Vue swallows before they reach the component's props." },
1470
+ docs: { description: "Disallow top-level schema field names that never reach the component's props." },
1661
1471
  schema: [],
1662
- messages: { reserved: "Vue consumes '{{name}}' before it reaches the component, so this field never arrives as a prop. Pick another name, e.g. variant for a style selector." }
1472
+ messages: {
1473
+ reserved: "'{{name}}' never arrives as a prop: Vue handles key, ref, ref_for and ref_key itself, and merges class and style into the root element. Pick another name, e.g. variant for a style selector.",
1474
+ slots: "frontend-core passes a section's blocks in its slots prop, so a field named slots is overwritten. Pick another name."
1475
+ }
1663
1476
  },
1664
1477
  applies: (file) => file.side === "app",
1665
1478
  create: ({ report }) => ({ CallExpression: (node) => {
@@ -1669,36 +1482,12 @@ var reserved_default = defineRule({
1669
1482
  messageId: "reserved",
1670
1483
  data: { name: name.value }
1671
1484
  });
1485
+ else if (name?.value === "slots" && definition?.definer === "defineSection") report({
1486
+ node: name.node,
1487
+ messageId: "slots"
1488
+ });
1672
1489
  } })
1673
1490
  });
1674
- var id_default = defineRule({
1675
- meta: {
1676
- type: "problem",
1677
- docs: { description: "Disallow an id on the root element of a section or block, which frontend-core replaces with its own." },
1678
- schema: [],
1679
- messages: { rootId: "frontend-core gives the root element of every section and block its own id, so this one never reaches the page. Put it on an inner element." }
1680
- },
1681
- applies: (file) => file.side === "app" && file.path.endsWith(".vue"),
1682
- create: ({ context, report }) => {
1683
- let defined = false;
1684
- return {
1685
- "CallExpression": (node) => {
1686
- defined ||= readDefinition(node) !== void 0;
1687
- },
1688
- "Program:exit": () => {
1689
- const template = context.sourceCode.ast.templateBody;
1690
- if (!defined || !template) return;
1691
- for (const root of template.children) {
1692
- const id = root.type === "VElement" ? findAttribute(root, "id") : void 0;
1693
- if (id) report({
1694
- loc: id.loc,
1695
- messageId: "rootId"
1696
- });
1697
- }
1698
- }
1699
- };
1700
- }
1701
- });
1702
1491
  const parseVueFile = cachedReader((text) => {
1703
1492
  try {
1704
1493
  return {
@@ -1713,6 +1502,7 @@ const parseVueFile = cachedReader((text) => {
1713
1502
  return;
1714
1503
  }
1715
1504
  });
1505
+ const OWN_ATTRS = /inheritAttrs\s*:\s*false/;
1716
1506
  var root_default = defineRule({
1717
1507
  meta: {
1718
1508
  type: "problem",
@@ -1733,7 +1523,7 @@ var root_default = defineRule({
1733
1523
  "Program:exit": () => {
1734
1524
  const program = context.sourceCode.ast;
1735
1525
  const template = program.templateBody;
1736
- if (!defined || !template) return;
1526
+ if (!defined || !template || OWN_ATTRS.test(context.sourceCode.text)) return;
1737
1527
  const [root, second] = renderedRoots(template);
1738
1528
  if (second) {
1739
1529
  report({
@@ -1745,7 +1535,7 @@ var root_default = defineRule({
1745
1535
  const file = root && importedComponent(program, context.filename, root.rawName);
1746
1536
  const component = file ? parseVueFile(file) : void 0;
1747
1537
  const componentTemplate = component?.ast.templateBody;
1748
- if (root && componentTemplate && renderedRoots(componentTemplate).length > 1 && !/inheritAttrs\s*:\s*false/.test(component.text)) report({
1538
+ if (root && componentTemplate && renderedRoots(componentTemplate).length > 1 && !OWN_ATTRS.test(component.text)) report({
1749
1539
  loc: root.startTag.loc,
1750
1540
  messageId: "componentRoots",
1751
1541
  data: { name: root.rawName }
@@ -1757,9 +1547,9 @@ var root_default = defineRule({
1757
1547
  var slots_default = defineRule({
1758
1548
  meta: {
1759
1549
  type: "problem",
1760
- docs: { description: "Disallow counting slot children, since Studio passes all blocks of a slot as one Fragment." },
1550
+ docs: { description: "Disallow counting slot children, since frontend-core passes all blocks of a slot as one Fragment." },
1761
1551
  schema: [],
1762
- messages: { length: "Studio passes all blocks of a slot as one Fragment, so this length is 1. Count the rendered blocks with a helper that flattens Fragments." }
1552
+ messages: { length: "frontend-core passes all blocks of a slot as one Fragment, so this length is 1. Count the rendered blocks with a helper that flattens Fragments." }
1763
1553
  },
1764
1554
  applies: (file) => file.side === "app",
1765
1555
  create: ({ report, visitTemplate }) => {
@@ -1797,7 +1587,7 @@ var bundle_default = defineRule({
1797
1587
  } },
1798
1588
  additionalProperties: false
1799
1589
  }],
1800
- messages: { heavy: "Laioutr registers sections, blocks and plugins globally, so '{{name}}' lands in the chunk every page loads. Load it with import() or defineAsyncComponent() instead." }
1590
+ messages: { heavy: "frontend-core imports every section and block up front, and plugins run on every page, so '{{name}}' lands in the chunk every page loads. Load it with import() or defineAsyncComponent() instead." }
1801
1591
  },
1802
1592
  applies: (file) => file.kind === "section" || file.kind === "block" || file.kind === "app-plugin",
1803
1593
  create: ({ context, report }) => {
@@ -1812,6 +1602,21 @@ var bundle_default = defineRule({
1812
1602
  } };
1813
1603
  }
1814
1604
  });
1605
+ const readPackageFile = cachedReader((text) => {
1606
+ const json = JSON.parse(text);
1607
+ return {
1608
+ name: json.name,
1609
+ dependencies: new Set([
1610
+ json.dependencies,
1611
+ json.devDependencies,
1612
+ json.peerDependencies,
1613
+ json.optionalDependencies
1614
+ ].flatMap((deps) => Object.keys(deps ?? {})))
1615
+ };
1616
+ });
1617
+ function readPackage(root) {
1618
+ return readPackageFile(join(root, "package.json"));
1619
+ }
1815
1620
  function isRuntimeConfig(node, variables) {
1816
1621
  if (node.type === "CallExpression") return node.callee.type === "Identifier" && node.callee.name === "useRuntimeConfig";
1817
1622
  if (node.type === "Identifier") return variables.has(node.name);
@@ -1856,7 +1661,7 @@ var public_default = defineRule({
1856
1661
  type: "problem",
1857
1662
  docs: { description: "Disallow copying module options into the public runtime config, which reaches the browser." },
1858
1663
  schema: [],
1859
- messages: { options: "runtimeConfig.public reaches the browser, and with it every token or secret among the module options. Copy only the options meant to be public." }
1664
+ messages: { options: "runtimeConfig.public reaches the browser, and with it every token or secret among the module options. Copy only the options meant to be public, e.g. options.storefrontUrl." }
1860
1665
  },
1861
1666
  applies: (file) => file.side === "build",
1862
1667
  create: ({ report }) => ({ AssignmentExpression: (node) => {
@@ -1867,30 +1672,13 @@ var public_default = defineRule({
1867
1672
  walk(node.right, (child) => {
1868
1673
  if (child.type !== "Identifier" || child.name !== options || !isReference(child)) return;
1869
1674
  const { parent } = child;
1870
- if (!(parent.type === "MemberExpression" && parent.object === child && nameOf(parent) === "public")) report({
1675
+ if (parent.type !== "MemberExpression" || parent.object !== child) report({
1871
1676
  node: child,
1872
1677
  messageId: "options"
1873
1678
  });
1874
1679
  });
1875
1680
  } })
1876
1681
  });
1877
- var folders_default = defineRule({
1878
- meta: {
1879
- type: "suggestion",
1880
- docs: { description: "Require runtime files to live in the folders of the Laioutr app layout." },
1881
- schema: [],
1882
- messages: { unknown: "larrylint doesn't know {{folder}}, so no rule checks what's in it. Move the file into one of the folders of the Laioutr app layout." }
1883
- },
1884
- applies: (file) => (file.side === "app" || file.side === "server") && file.kind === "other",
1885
- create: ({ file, report }) => ({ Program: () => {
1886
- const [side, folder] = file.path.slice(file.path.indexOf("/runtime/") + 9).split("/");
1887
- report({
1888
- loc: FILE_START,
1889
- messageId: "unknown",
1890
- data: { folder: `${side}/${folder}/` }
1891
- });
1892
- } })
1893
- });
1894
1682
  const LABELS = {
1895
1683
  "section": "section",
1896
1684
  "block": "block",
@@ -1925,13 +1713,7 @@ const ABOVE_SERVER_UTILS = /* @__PURE__ */ new Set([
1925
1713
  "orchestr-file",
1926
1714
  "media-library"
1927
1715
  ]);
1928
- const BUILDER_USERS = /* @__PURE__ */ new Set([
1929
- "handler",
1930
- "orchestr-file",
1931
- "middleware",
1932
- "media-library"
1933
- ]);
1934
- function findViolation(importer, target, typeOnly, sharedDomains) {
1716
+ function findViolation(importer, target, typeOnly) {
1935
1717
  if (importer.side === "app" && target.side === "server") return { messageId: "appImportsServer" };
1936
1718
  if (importer.side === "server" && target.side === "app") return { messageId: "serverImportsApp" };
1937
1719
  if (importer.side === "shared" && (target.side === "app" || target.side === "server")) return {
@@ -1942,25 +1724,10 @@ function findViolation(importer, target, typeOnly, sharedDomains) {
1942
1724
  if (target.kind === "handler") return { messageId: "handlerImported" };
1943
1725
  if (typeOnly) return;
1944
1726
  if (target.kind === "client" && importer.kind === "handler") return { messageId: "clientInHandler" };
1945
- if (target.kind === "client" && importer.kind === "server-util") return { messageId: "clientInUtil" };
1946
- if (target.kind === "middleware" && !BUILDER_USERS.has(importer.kind)) return { messageId: "middlewareImported" };
1947
1727
  if (importer.kind === "server-util" && ABOVE_SERVER_UTILS.has(target.kind)) return {
1948
1728
  messageId: "serverUtilImportsUp",
1949
1729
  data: { kind: LABELS[target.kind] }
1950
1730
  };
1951
- const from = domainOf(importer);
1952
- const to = domainOf(target);
1953
- if (from && to && from !== to && !sharedDomains.includes(to)) return {
1954
- messageId: "crossDomain",
1955
- data: {
1956
- from,
1957
- to
1958
- }
1959
- };
1960
- if (importer.kind === "server-util" && !from && to && !sharedDomains.includes(to)) return {
1961
- messageId: "sharedImportsDomain",
1962
- data: { to }
1963
- };
1964
1731
  if (importer.kind === "app-util" && ABOVE_APP_UTILS.has(target.kind)) return {
1965
1732
  messageId: "appUtilImportsUp",
1966
1733
  data: { kind: LABELS[target.kind] }
@@ -1974,15 +1741,8 @@ function findViolation(importer, target, typeOnly, sharedDomains) {
1974
1741
  var layers_default = defineRule({
1975
1742
  meta: {
1976
1743
  type: "problem",
1977
- docs: { description: "Enforce the layers of a Laioutr app: app, server and shared code, orchestr handlers, middleware, clients, utils and domains." },
1978
- schema: [{
1979
- type: "object",
1980
- properties: { sharedDomains: {
1981
- type: "array",
1982
- items: { type: "string" }
1983
- } },
1984
- additionalProperties: false
1985
- }],
1744
+ docs: { description: "Enforce the layers of a Laioutr app: app, server and shared code, build-time code, orchestr handlers, API clients and utils." },
1745
+ schema: [],
1986
1746
  messages: {
1987
1747
  appImportsServer: "App code can't import server code. Move what both sides need to src/runtime/shared/.",
1988
1748
  serverImportsApp: "Server code can't import app code. Move what both sides need to src/runtime/shared/.",
@@ -1990,11 +1750,7 @@ var layers_default = defineRule({
1990
1750
  runtimeImportsBuild: "Runtime code can't import build-time code from src/; it isn't part of the runtime bundle. Type imports are fine.",
1991
1751
  handlerImported: "Orchestr handlers are registered by laioutr, never imported. Move the shared code to server/utils/.",
1992
1752
  clientInHandler: "Handlers read API clients from the orchestr context. server/client/ only holds clients; move constants and helpers to server/utils/.",
1993
- clientInUtil: "Server utils get API clients passed in. server/client/ only holds clients; move constants and helpers to server/utils/.",
1994
- middlewareImported: "Only orchestr handlers and media libraries import middleware. Move helpers like this to server/utils/.",
1995
1753
  serverUtilImportsUp: "Server utils are the bottom layer and can't import {{kind}} code.",
1996
- crossDomain: "The {{from}} domain can't import from the {{to}} domain. Move the shared code out of the {{to}} folders into server/utils/, or add '{{to}}' to sharedDomains.",
1997
- sharedImportsDomain: "Shared server utils can't import from the {{to}} domain, or every domain that uses them depends on {{to}}. Move the shared code out of the {{to}} folders, or add '{{to}}' to sharedDomains.",
1998
1754
  appUtilImportsUp: "App utils are the bottom layer and can't import a {{kind}}. Type imports are fine.",
1999
1755
  composableImportsUp: "Composables can't import a {{kind}}. Type imports are fine.",
2000
1756
  componentImportsSection: "Components can't import sections; sections compose components.",
@@ -2003,7 +1759,6 @@ var layers_default = defineRule({
2003
1759
  },
2004
1760
  applies: (file) => file.side === "app" || file.side === "server" || file.side === "shared",
2005
1761
  create: ({ context, file: importer, report }) => {
2006
- const { sharedDomains = [] } = context.options[0] ?? {};
2007
1762
  const checkImport = (node, source, typeOnly) => {
2008
1763
  if (typeof source !== "string") return;
2009
1764
  if (source.startsWith("node:")) {
@@ -2016,7 +1771,7 @@ var layers_default = defineRule({
2016
1771
  }
2017
1772
  const path = resolveImport(context.filename, source);
2018
1773
  const target = path ? classify(path) : void 0;
2019
- const violation = target && findViolation(importer, target, typeOnly, sharedDomains);
1774
+ const violation = target && findViolation(importer, target, typeOnly);
2020
1775
  if (violation) report({
2021
1776
  node,
2022
1777
  ...violation
@@ -2045,55 +1800,40 @@ const plugin = {
2045
1800
  },
2046
1801
  rules: {
2047
1802
  "layers": layers_default,
2048
- "known-folders": folders_default,
2049
1803
  "heavy-imports": bundle_default,
2050
1804
  "config-keys": keys_default,
2051
1805
  "public-config": public_default,
2052
1806
  "orchestr-files": files_default$1,
2053
1807
  "orchestr-cookies": cookies_default,
2054
- "handler-domains": domains_default,
2055
1808
  "handler-exports": exports_default,
2056
- "handler-context": context_default,
2057
- "resolver-undefined": returns_default,
2058
- "resolver-passthrough": passthrough_default,
2059
- "action-errors": errors_default,
2060
1809
  "middleware-files": files_default,
2061
1810
  "initware-throws": throws_default,
2062
- "orchestr-meta": meta_default,
2063
1811
  "token-namespaces": namespaces_default,
2064
- "token-nullable": nullable_default,
2065
1812
  "definition-folder": folder_default,
2066
- "definition-prefix": prefix_default,
2067
1813
  "component-name": name_default,
2068
1814
  "definition-description": description_default,
2069
- "root-id": id_default,
2070
1815
  "single-root": root_default,
2071
1816
  "slot-children": slots_default,
2072
1817
  "reserved-field-names": reserved_default,
2073
1818
  "field-name-case": casing_default,
2074
1819
  "required-fields": required_default,
2075
- "schema-groups": groups_default,
2076
1820
  "unused-fields": unused_default,
2077
1821
  "dead-fallbacks": fallbacks_default,
2078
1822
  "button-type": button_default,
2079
- "ui-kit-tags": tags_default,
2080
1823
  "mutation-errors": mutations_default,
2081
- "orchestr-store": store_default,
2082
1824
  "resolve-result": resolve_default,
2083
1825
  "hand-built-links": paths_default,
2084
- "internal-anchors": anchors_default,
2085
- "money": money_default
1826
+ "internal-anchors": anchors_default
2086
1827
  }
2087
1828
  };
2088
1829
  async function larrylint(options = {}) {
2089
- const { sharedDomains, heavyPackages } = await loadLarrylintConfig(options.cwd);
1830
+ const { heavyPackages } = await loadLarrylintConfig(options.cwd);
2090
1831
  return [{
2091
1832
  name: "larrylint",
2092
1833
  files: ["**/src/**/*.{ts,mts,cts,js,mjs,cjs,vue}"],
2093
1834
  plugins: { larrylint: plugin },
2094
1835
  rules: {
2095
1836
  ...Object.fromEntries(Object.keys(plugin.rules).map((id) => [`larrylint/${id}`, "error"])),
2096
- "larrylint/layers": ["error", { sharedDomains }],
2097
1837
  "larrylint/heavy-imports": ["error", { packages: heavyPackages }]
2098
1838
  }
2099
1839
  }];