@happyvertical/smrt-scanner 0.55.1 → 0.55.3

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,5 +1,5 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { isAbsolute, relative, resolve, sep, win32 } from "node:path";
2
+ import { dirname, isAbsolute, relative, resolve, sep, win32 } from "node:path";
3
3
  import fg from "fast-glob";
4
4
  import { parseSync } from "oxc-parser";
5
5
  //#region src/source-location.ts
@@ -759,6 +759,87 @@ function normalizeGlobSeparators(pattern, pathSeparator = sep) {
759
759
  return pathSeparator === "/" ? pattern : pattern.replaceAll(pathSeparator, "/");
760
760
  }
761
761
  //#endregion
762
+ //#region src/enum-values.ts
763
+ var MAX_ALIAS_DEPTH = 8;
764
+ function splitUnion(type) {
765
+ const parts = [];
766
+ let current = "";
767
+ let quote = null;
768
+ let escaped = false;
769
+ for (const ch of type) {
770
+ if (quote) {
771
+ current += ch;
772
+ if (escaped) escaped = false;
773
+ else if (ch === "\\") escaped = true;
774
+ else if (ch === quote) quote = null;
775
+ continue;
776
+ }
777
+ if (ch === "'" || ch === "\"") {
778
+ quote = ch;
779
+ current += ch;
780
+ } else if (ch === "|") {
781
+ parts.push(current.trim());
782
+ current = "";
783
+ } else current += ch;
784
+ }
785
+ parts.push(current.trim());
786
+ return parts.filter((part) => part !== "");
787
+ }
788
+ function resolveMember(member, aliases, depth, out) {
789
+ const quoted = member.match(/^(['"])(.*)\1$/s);
790
+ if (quoted) try {
791
+ let encoded = member;
792
+ if (quoted[1] === "'") {
793
+ encoded = "\"";
794
+ for (let index = 0; index < quoted[2].length; index += 1) {
795
+ const char = quoted[2][index];
796
+ if (char === "\\" && quoted[2][index + 1]) {
797
+ const escapedChar = quoted[2][index + 1];
798
+ encoded += escapedChar === "'" ? "'" : `\\${escapedChar}`;
799
+ index += 1;
800
+ } else encoded += char === "\"" ? "\\\"" : char;
801
+ }
802
+ encoded += "\"";
803
+ }
804
+ const value = JSON.parse(encoded);
805
+ if (typeof value !== "string") return false;
806
+ out.push(value);
807
+ return true;
808
+ } catch {
809
+ return false;
810
+ }
811
+ if (/^-?\d+(\.\d+)?$/.test(member)) {
812
+ out.push(Number(member));
813
+ return true;
814
+ }
815
+ if (member === "null" || member === "undefined") return true;
816
+ if (depth < MAX_ALIAS_DEPTH && /^[A-Za-z_$][\w$]*$/.test(member) && Object.hasOwn(aliases, member)) {
817
+ const nested = collect$1(aliases[member], aliases, depth + 1);
818
+ if (!nested) return false;
819
+ out.push(...nested);
820
+ return true;
821
+ }
822
+ return false;
823
+ }
824
+ function collect$1(type, aliases, depth) {
825
+ const out = [];
826
+ for (const member of splitUnion(type)) if (!resolveMember(member, aliases, depth, out)) return void 0;
827
+ return out;
828
+ }
829
+ function resolveEnumValues(type, aliases = {}) {
830
+ if (!type) return void 0;
831
+ const values = collect$1(type.trim(), aliases, 0);
832
+ if (!values || values.length === 0) return void 0;
833
+ const seen = /* @__PURE__ */ new Set();
834
+ const unique = [];
835
+ for (const value of values) {
836
+ if (seen.has(value)) continue;
837
+ seen.add(value);
838
+ unique.push(value);
839
+ }
840
+ return unique;
841
+ }
842
+ //#endregion
762
843
  //#region src/inheritance-resolver.ts
763
844
  var FRAMEWORK_BASE_CLASSES = /* @__PURE__ */ new Set([
764
845
  "SmrtObject",
@@ -1076,6 +1157,606 @@ var InheritanceResolver = class {
1076
1157
  }
1077
1158
  };
1078
1159
  //#endregion
1160
+ //#region src/recipes.ts
1161
+ var RECIPE_BASE = "SmrtRecipe";
1162
+ var CORE_SPECIFIER = "@happyvertical/smrt-core";
1163
+ function isCoreSpecifier(source) {
1164
+ return source === CORE_SPECIFIER || source.startsWith(`${CORE_SPECIFIER}/`);
1165
+ }
1166
+ var RECIPE_ID_PATTERN = /^[a-z][a-z0-9]*(?:\.[a-z0-9][a-z0-9_-]*)+$/;
1167
+ var RECIPE_ID_MAX_LENGTH = 128;
1168
+ var FIELD_OPTION_KEYS = /* @__PURE__ */ new Set([
1169
+ "default",
1170
+ "label",
1171
+ "help",
1172
+ "order",
1173
+ "visibility",
1174
+ "locked"
1175
+ ]);
1176
+ var VISIBILITIES = /* @__PURE__ */ new Set([
1177
+ "basic",
1178
+ "advanced",
1179
+ "hidden"
1180
+ ]);
1181
+ var EXPOSURE_TRANSPORTS = [
1182
+ "api",
1183
+ "mcp",
1184
+ "cli"
1185
+ ];
1186
+ function sourceMayDeclareRecipe(sourceText) {
1187
+ return sourceText.includes(RECIPE_BASE);
1188
+ }
1189
+ var RecipeReadError = class extends Error {
1190
+ constructor(message, node) {
1191
+ super(message);
1192
+ this.node = node;
1193
+ }
1194
+ node;
1195
+ };
1196
+ function collectImports(body) {
1197
+ const bindings = /* @__PURE__ */ new Map();
1198
+ for (const node of body) {
1199
+ if (node.type !== "ImportDeclaration") continue;
1200
+ const source = node.source?.value;
1201
+ if (typeof source !== "string") continue;
1202
+ for (const spec of node.specifiers ?? []) {
1203
+ const local = spec.local?.name;
1204
+ if (typeof local !== "string") continue;
1205
+ if (spec.type === "ImportSpecifier") {
1206
+ const imported = spec.imported;
1207
+ const name = imported?.name ?? imported?.value;
1208
+ if (typeof name === "string") bindings.set(local, {
1209
+ imported: name,
1210
+ source,
1211
+ namespace: false
1212
+ });
1213
+ } else if (spec.type === "ImportDefaultSpecifier") bindings.set(local, {
1214
+ imported: local,
1215
+ source,
1216
+ namespace: false
1217
+ });
1218
+ else if (spec.type === "ImportNamespaceSpecifier") bindings.set(local, {
1219
+ imported: "*",
1220
+ source,
1221
+ namespace: true
1222
+ });
1223
+ }
1224
+ }
1225
+ return bindings;
1226
+ }
1227
+ function unwrap(node) {
1228
+ let current = node;
1229
+ for (let depth = 0; depth < 8; depth++) {
1230
+ if (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSNonNullExpression" || current.type === "TSTypeAssertion" || current.type === "ParenthesizedExpression") {
1231
+ current = current.expression;
1232
+ continue;
1233
+ }
1234
+ break;
1235
+ }
1236
+ return current;
1237
+ }
1238
+ function isRecipeBase(superClass, imports) {
1239
+ if (!superClass) return false;
1240
+ const node = unwrap(superClass);
1241
+ if (node.type === "Identifier") {
1242
+ const binding = imports.get(node.name);
1243
+ return !!binding && binding.imported === RECIPE_BASE && isCoreSpecifier(binding.source);
1244
+ }
1245
+ if (node.type === "MemberExpression" && node.computed !== true) {
1246
+ const object = node.object;
1247
+ const property = node.property;
1248
+ const binding = object.type === "Identifier" ? imports.get(object.name) : void 0;
1249
+ return !!binding && binding.namespace && isCoreSpecifier(binding.source) && property.name === RECIPE_BASE;
1250
+ }
1251
+ return false;
1252
+ }
1253
+ function keyName(node) {
1254
+ if (node.computed === true) return null;
1255
+ const key = node.key;
1256
+ if (!key) return null;
1257
+ if (key.type === "Identifier") return key.name;
1258
+ if (key.type === "Literal" && typeof key.value === "string") return key.value;
1259
+ return null;
1260
+ }
1261
+ function stringValue(node, what) {
1262
+ const value = unwrap(node);
1263
+ if (value.type === "Literal" && typeof value.value === "string") return value.value;
1264
+ if (value.type === "TemplateLiteral" && value.expressions.length === 0) {
1265
+ const cooked = value.quasis[0]?.value?.cooked;
1266
+ if (typeof cooked === "string") return cooked;
1267
+ }
1268
+ throw new RecipeReadError(`${what} must be a string literal`, node);
1269
+ }
1270
+ function arrayElements(node, what) {
1271
+ const value = unwrap(node);
1272
+ if (value.type !== "ArrayExpression") throw new RecipeReadError(`${what} must be an array literal`, node);
1273
+ return value.elements.map((element) => {
1274
+ if (!element || element.type === "SpreadElement") throw new RecipeReadError(`${what} must list its entries literally (no spread or holes)`, element ?? node);
1275
+ return element;
1276
+ });
1277
+ }
1278
+ function objectEntries(node, what) {
1279
+ const value = unwrap(node);
1280
+ if (value.type !== "ObjectExpression") throw new RecipeReadError(`${what} must be an object literal`, node);
1281
+ return value.properties.map((property) => {
1282
+ if (property.type !== "Property" || property.kind !== "init") throw new RecipeReadError(`${what} must use plain \`key: value\` properties (no spread, getters, or methods)`, property);
1283
+ if (property.method === true) throw new RecipeReadError(`${what} cannot contain methods`, property);
1284
+ const key = keyName(property);
1285
+ if (key === null) throw new RecipeReadError(`${what} cannot use computed property keys`, property);
1286
+ return {
1287
+ key,
1288
+ value: property.value,
1289
+ node: property
1290
+ };
1291
+ });
1292
+ }
1293
+ function literalValue(node, what, depth = 0) {
1294
+ if (depth > 16) throw new RecipeReadError(`${what} is nested too deeply`, node);
1295
+ const value = unwrap(node);
1296
+ switch (value.type) {
1297
+ case "Literal":
1298
+ if (value.regex || typeof value.value === "bigint") break;
1299
+ return value.value;
1300
+ case "TemplateLiteral": return stringValue(value, what);
1301
+ case "UnaryExpression": {
1302
+ const argument = unwrap(value.argument);
1303
+ if ((value.operator === "-" || value.operator === "+") && argument.type === "Literal" && typeof argument.value === "number") return value.operator === "-" ? -argument.value : argument.value;
1304
+ break;
1305
+ }
1306
+ case "ArrayExpression": return arrayElements(value, what).map((element) => literalValue(element, what, depth + 1));
1307
+ case "ObjectExpression": {
1308
+ const out = {};
1309
+ for (const entry of objectEntries(value, what)) {
1310
+ if (isUnsafeKey(entry.key)) throw new RecipeReadError(`${what} cannot use the key \`${entry.key}\``, entry.node);
1311
+ out[entry.key] = literalValue(entry.value, what, depth + 1);
1312
+ }
1313
+ return out;
1314
+ }
1315
+ default: break;
1316
+ }
1317
+ throw new RecipeReadError(`${what} must be a literal value; the scanner cannot evaluate expressions`, node);
1318
+ }
1319
+ function isUnsafeKey(key) {
1320
+ return key === "__proto__" || key === "constructor" || key === "prototype";
1321
+ }
1322
+ function modelRef(node, imports, declaredHere, what, sourceText) {
1323
+ const value = unwrap(node);
1324
+ const line = lineOf(sourceText, value);
1325
+ if (value.type === "Identifier") {
1326
+ const local = value.name;
1327
+ const binding = imports.get(local);
1328
+ if (binding && !binding.namespace) return {
1329
+ local,
1330
+ imported: binding.imported,
1331
+ source: binding.source,
1332
+ line
1333
+ };
1334
+ if (declaredHere.has(local)) return {
1335
+ local,
1336
+ imported: local,
1337
+ source: null,
1338
+ line
1339
+ };
1340
+ throw new RecipeReadError(`${what} \`${local}\` is neither imported nor declared in this file`, node);
1341
+ }
1342
+ if (value.type === "MemberExpression" && value.computed !== true && value.object.type === "Identifier") {
1343
+ const object = value.object.name;
1344
+ const binding = imports.get(object);
1345
+ const property = value.property.name;
1346
+ if (binding?.namespace && typeof property === "string") return {
1347
+ local: `${object}.${property}`,
1348
+ imported: property,
1349
+ source: binding.source,
1350
+ line
1351
+ };
1352
+ }
1353
+ throw new RecipeReadError(`${what} must be a class reference (an imported or same-file class identifier), not an expression or a string`, node);
1354
+ }
1355
+ function lineOf(sourceText, node) {
1356
+ const start = node.start;
1357
+ return typeof start === "number" ? getLineColumn(sourceText, start)?.line : void 0;
1358
+ }
1359
+ function extractRecipes(input) {
1360
+ const body = input.body;
1361
+ const imports = collectImports(body);
1362
+ const recipes = [];
1363
+ const errors = [];
1364
+ const classes = [];
1365
+ const declaredHere = /* @__PURE__ */ new Set();
1366
+ for (const statement of body) {
1367
+ let node = statement;
1368
+ if ((node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration") && node.declaration) node = node.declaration;
1369
+ if (node.type === "ClassDeclaration") {
1370
+ classes.push(node);
1371
+ const name = node.id?.name;
1372
+ if (typeof name === "string") declaredHere.add(name);
1373
+ }
1374
+ }
1375
+ const recipeNames = /* @__PURE__ */ new Set();
1376
+ for (const node of classes) if (isRecipeBase(node.superClass, imports)) recipeNames.add(node.id?.name ?? "");
1377
+ const reportAt = (message, at) => {
1378
+ const loc = typeof at.start === "number" ? getLineColumn(input.sourceText, at.start) : void 0;
1379
+ errors.push({
1380
+ message,
1381
+ filePath: input.filePath,
1382
+ line: loc?.line,
1383
+ column: loc?.column,
1384
+ severity: "error"
1385
+ });
1386
+ };
1387
+ for (const node of classes) {
1388
+ const base = node.superClass ? unwrap(node.superClass) : void 0;
1389
+ if (base?.type === "Identifier" && recipeNames.has(base.name) && !isRecipeBase(base, imports)) reportAt(`Recipe ${node.id?.name ?? "AnonymousRecipe"}: extends the recipe ${base.name}; a recipe must extend SmrtRecipe directly`, node);
1390
+ }
1391
+ for (const found of findClassExpressions(body)) if (isRecipeBase(found.superClass, imports)) reportAt("Recipe declared as a class expression or inside a block; declare it as a top-level `class X extends SmrtRecipe`", found);
1392
+ for (const node of classes) {
1393
+ if (!isRecipeBase(node.superClass, imports)) continue;
1394
+ const className = node.id?.name ?? "AnonymousRecipe";
1395
+ const report = (message, at) => {
1396
+ const loc = typeof at?.start === "number" ? getLineColumn(input.sourceText, at.start) : void 0;
1397
+ errors.push({
1398
+ message: `Recipe ${className}: ${message}`,
1399
+ filePath: input.filePath,
1400
+ line: loc?.line,
1401
+ column: loc?.column,
1402
+ severity: "error"
1403
+ });
1404
+ };
1405
+ const recipe = {
1406
+ className,
1407
+ filePath: input.filePath,
1408
+ line: lineOf(input.sourceText, node),
1409
+ id: null,
1410
+ label: null,
1411
+ summary: null,
1412
+ synonyms: [],
1413
+ models: [],
1414
+ nav: [],
1415
+ requires: [],
1416
+ options: [],
1417
+ help: null
1418
+ };
1419
+ const seen = /* @__PURE__ */ new Set();
1420
+ for (const member of node.body.body) {
1421
+ if (member.type !== "PropertyDefinition" || member.static !== true) continue;
1422
+ const name = keyName(member);
1423
+ if (!name || !member.value) continue;
1424
+ const value = member.value;
1425
+ try {
1426
+ switch (name) {
1427
+ case "id":
1428
+ recipe.id = stringValue(value, "static id");
1429
+ break;
1430
+ case "label":
1431
+ recipe.label = stringValue(value, "static label");
1432
+ break;
1433
+ case "summary":
1434
+ recipe.summary = stringValue(value, "static summary");
1435
+ break;
1436
+ case "synonyms":
1437
+ recipe.synonyms = arrayElements(value, "static synonyms").map((entry) => stringValue(entry, "static synonyms entries"));
1438
+ break;
1439
+ case "requires":
1440
+ recipe.requires = arrayElements(value, "static requires").map((entry) => stringValue(entry, "static requires entries"));
1441
+ break;
1442
+ case "models":
1443
+ recipe.models = arrayElements(value, "static models").map((entry) => modelRef(entry, imports, declaredHere, "static models entry", input.sourceText));
1444
+ break;
1445
+ case "nav":
1446
+ recipe.nav = arrayElements(value, "static nav").map((entry) => {
1447
+ const fields = objectEntries(entry, "static nav entries");
1448
+ const label = fields.find((field) => field.key === "label");
1449
+ const model = fields.find((field) => field.key === "model");
1450
+ const extra = fields.find((field) => field.key !== "label" && field.key !== "model");
1451
+ if (extra) throw new RecipeReadError(`static nav entries accept only \`label\` and \`model\`, not \`${extra.key}\``, extra.node);
1452
+ if (!label || !model) throw new RecipeReadError("static nav entries need both `label` and `model`", entry);
1453
+ return {
1454
+ label: stringValue(label.value, "static nav label"),
1455
+ model: modelRef(model.value, imports, declaredHere, "static nav model", input.sourceText),
1456
+ line: lineOf(input.sourceText, entry)
1457
+ };
1458
+ });
1459
+ break;
1460
+ case "options":
1461
+ recipe.options = objectEntries(value, "static options").map((entry) => ({
1462
+ key: entry.key,
1463
+ value: literalValue(entry.value, "static options"),
1464
+ line: lineOf(input.sourceText, entry.node)
1465
+ }));
1466
+ break;
1467
+ case "help":
1468
+ recipe.help = {
1469
+ path: stringValue(value, "static help"),
1470
+ line: lineOf(input.sourceText, member)
1471
+ };
1472
+ break;
1473
+ default: continue;
1474
+ }
1475
+ seen.add(name);
1476
+ } catch (error) {
1477
+ if (!(error instanceof RecipeReadError)) throw error;
1478
+ report(error.message, error.node ?? member);
1479
+ }
1480
+ }
1481
+ for (const required of [
1482
+ "id",
1483
+ "label",
1484
+ "summary",
1485
+ "models"
1486
+ ]) if (!seen.has(required)) report(`must declare a readable \`static ${required}\``, node.id);
1487
+ recipes.push(recipe);
1488
+ }
1489
+ return {
1490
+ recipes,
1491
+ errors
1492
+ };
1493
+ }
1494
+ var HELP_EXTENSION = ".md";
1495
+ var FIELD_REF_PATTERN = /\{field:([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?)\}/g;
1496
+ function deriveHelpFieldRefs(markdown) {
1497
+ const refs = /* @__PURE__ */ new Set();
1498
+ for (const block of markdown.replace(/\r\n?/g, "\n").split(/\n\s*\n/)) for (const match of block.replace(/`[^`]+`/g, "").matchAll(FIELD_REF_PATTERN)) refs.add(match[1]);
1499
+ return [...refs].sort();
1500
+ }
1501
+ function readHelp(help, raw) {
1502
+ const fail = (message) => {
1503
+ throw new RecipeReadError(`static help ${message}`);
1504
+ };
1505
+ if (help.path.trim() === "" || isAbsolute(help.path) || help.path.split(/[\\/]/).includes("..") || !help.path.toLowerCase().endsWith(HELP_EXTENSION)) return fail(`must be a relative path to a ${HELP_EXTENSION} file beside the recipe (got \`${help.path}\`)`);
1506
+ const absolute = resolve(dirname(raw.filePath), help.path);
1507
+ let markdown;
1508
+ try {
1509
+ markdown = readFileSync(absolute, "utf-8");
1510
+ } catch (error) {
1511
+ return fail(`file \`${help.path}\` cannot be read (${error instanceof Error ? error.message : String(error)})`);
1512
+ }
1513
+ markdown = markdown.replace(/\r\n?/g, "\n");
1514
+ if (markdown.trim() === "") return fail(`file \`${help.path}\` is empty`);
1515
+ return {
1516
+ markdown,
1517
+ fieldRefs: deriveHelpFieldRefs(markdown)
1518
+ };
1519
+ }
1520
+ function findClassExpressions(body) {
1521
+ const found = [];
1522
+ const topLevel = /* @__PURE__ */ new Set();
1523
+ for (const statement of body) {
1524
+ const node = (statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration") && statement.declaration ? statement.declaration : statement;
1525
+ if (node.type === "ClassDeclaration") topLevel.add(node);
1526
+ }
1527
+ const visit = (value, depth) => {
1528
+ if (depth > 200 || !value || typeof value !== "object") return;
1529
+ if (Array.isArray(value)) {
1530
+ for (const entry of value) visit(entry, depth + 1);
1531
+ return;
1532
+ }
1533
+ const node = value;
1534
+ if ((node.type === "ClassExpression" || node.type === "ClassDeclaration") && !topLevel.has(node)) found.push(node);
1535
+ for (const [key, child] of Object.entries(node)) {
1536
+ if (key === "loc" || key === "range") continue;
1537
+ visit(child, depth + 1);
1538
+ }
1539
+ };
1540
+ visit(body, 0);
1541
+ return found;
1542
+ }
1543
+ function stripExtension(path) {
1544
+ return path.replace(/\.(?:[cm]?[jt]sx?|svelte)$/, "");
1545
+ }
1546
+ function isRelativeSpecifier(source) {
1547
+ return source.startsWith("./") || source.startsWith("../");
1548
+ }
1549
+ function isExternalPackageSpecifier(source) {
1550
+ if (isRelativeSpecifier(source) || source.startsWith("/")) return false;
1551
+ if (/^[$~#]/.test(source) || source.startsWith("@/")) return false;
1552
+ return true;
1553
+ }
1554
+ function resolveModel(ref, recipe, classes, what) {
1555
+ const byName = classes.filter((candidate) => candidate.className === ref.imported);
1556
+ let candidates = byName;
1557
+ if (ref.source === null) candidates = byName.filter((c) => c.filePath === recipe.filePath);
1558
+ else if (isExternalPackageSpecifier(ref.source)) throw new RecipeReadError(`${what} \`${ref.local}\` is imported from \`${ref.source}\`, another package; a recipe's models must belong to its own package`);
1559
+ else if (isRelativeSpecifier(ref.source)) {
1560
+ const target = stripExtension(resolve(dirname(recipe.filePath), ref.source));
1561
+ const byFile = byName.filter((c) => {
1562
+ const file = stripExtension(c.filePath);
1563
+ return file === target || file === `${target}/index`;
1564
+ });
1565
+ candidates = byFile.length > 0 ? byFile : byName;
1566
+ }
1567
+ if (candidates.length === 0) throw new RecipeReadError(`${what} \`${ref.local}\` does not resolve to a model class scanned in this package`);
1568
+ if (candidates.length > 1) throw new RecipeReadError(`${what} \`${ref.local}\` is ambiguous: ${candidates.length} scanned classes are named \`${ref.imported}\``);
1569
+ return candidates[0];
1570
+ }
1571
+ var CRUD_VERBS = [
1572
+ "list",
1573
+ "get",
1574
+ "create",
1575
+ "update",
1576
+ "delete"
1577
+ ];
1578
+ function isNarrowing(value, operations) {
1579
+ if (value === false) return true;
1580
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1581
+ const keys = Object.keys(value);
1582
+ const exclude = value.exclude;
1583
+ return keys.length === 1 && keys[0] === "exclude" && Array.isArray(exclude) && exclude.length > 0 && exclude.every((entry) => typeof entry === "string" && operations.has(entry));
1584
+ }
1585
+ function isPlainObject(value) {
1586
+ return !!value && typeof value === "object" && !Array.isArray(value);
1587
+ }
1588
+ function readModelOptions(raw, model, classes, where, bad) {
1589
+ if (!isPlainObject(raw)) {
1590
+ bad(`${where} must be an object`);
1591
+ return;
1592
+ }
1593
+ let ok = true;
1594
+ const fail = (message) => {
1595
+ ok = false;
1596
+ bad(message);
1597
+ };
1598
+ const out = {};
1599
+ const operations = new Set(CRUD_VERBS);
1600
+ const chain = /* @__PURE__ */ new Set([model.className, ...model.inheritanceChain]);
1601
+ for (const candidate of classes) {
1602
+ if (!chain.has(candidate.className)) continue;
1603
+ for (const method of candidate.methods) if (!method.isStatic && method.accessibility === "public" && method.name !== "constructor") operations.add(method.name);
1604
+ }
1605
+ for (const [key, value] of Object.entries(raw)) if (key === "fields") {
1606
+ if (!isPlainObject(value)) {
1607
+ fail(`${where}.fields must be an object`);
1608
+ continue;
1609
+ }
1610
+ const fields = {};
1611
+ for (const [field, hints] of Object.entries(value)) {
1612
+ if (!isPlainObject(hints)) {
1613
+ fail(`${where}.fields.${field} must be an object`);
1614
+ continue;
1615
+ }
1616
+ const entry = {};
1617
+ for (const [hint, hintValue] of Object.entries(hints)) {
1618
+ const at = `${where}.fields.${field}.${hint}`;
1619
+ if (!FIELD_OPTION_KEYS.has(hint)) fail(`${at} is not a field option; use default, label, help, order, visibility, or locked`);
1620
+ else if ((hint === "label" || hint === "help") && typeof hintValue !== "string") fail(`${at} must be a string`);
1621
+ else if (hint === "order" && (typeof hintValue !== "number" || !Number.isFinite(hintValue))) fail(`${at} must be a finite number`);
1622
+ else if (hint === "locked" && typeof hintValue !== "boolean") fail(`${at} must be a boolean`);
1623
+ else if (hint === "visibility" && !(typeof hintValue === "string" && VISIBILITIES.has(hintValue))) fail(`${at} must be one of basic, advanced, hidden`);
1624
+ else entry[hint] = hintValue;
1625
+ }
1626
+ fields[field] = entry;
1627
+ }
1628
+ out.fields = fields;
1629
+ } else if (key === "exposure") {
1630
+ if (!isPlainObject(value)) {
1631
+ fail(`${where}.exposure must be an object`);
1632
+ continue;
1633
+ }
1634
+ const exposure = {};
1635
+ for (const [transport, narrowing] of Object.entries(value)) if (!EXPOSURE_TRANSPORTS.includes(transport)) fail(`${where}.exposure.${transport} is not a transport; use api, mcp, or cli`);
1636
+ else if (!isNarrowing(narrowing, operations)) fail(`${where}.exposure.${transport} may only be \`false\` or a non-empty \`{ exclude: [...] }\` naming ${[...CRUD_VERBS].join("/")} or a method of \`${model.className}\`: options narrow exposure and never widen it, so \`true\` and \`include\` are rejected`);
1637
+ else exposure[transport] = narrowing;
1638
+ out.exposure = exposure;
1639
+ } else fail(`${where}.${key} is not a model option; use \`fields\` or \`exposure\``);
1640
+ return ok ? out : void 0;
1641
+ }
1642
+ function resolveRecipes(raws, classes) {
1643
+ const errors = [];
1644
+ const recipes = [];
1645
+ const ids = /* @__PURE__ */ new Map();
1646
+ for (const raw of raws) {
1647
+ const report = (message, line) => errors.push({
1648
+ message: `Recipe ${raw.className}: ${message}`,
1649
+ filePath: raw.filePath,
1650
+ line: line ?? raw.line,
1651
+ severity: "error"
1652
+ });
1653
+ const attempt = (work, line) => {
1654
+ try {
1655
+ return work();
1656
+ } catch (error) {
1657
+ if (!(error instanceof RecipeReadError)) throw error;
1658
+ report(error.message, line);
1659
+ return;
1660
+ }
1661
+ };
1662
+ if (raw.id === null || raw.label === null || raw.summary === null) continue;
1663
+ const id = raw.id;
1664
+ if (id.length > RECIPE_ID_MAX_LENGTH || !RECIPE_ID_PATTERN.test(id)) report(`id \`${id}\` must be dotted lowercase segments such as \`commerce.sales\``);
1665
+ const first = ids.get(id);
1666
+ if (first) report(`id \`${id}\` is already declared by ${first.className} (${first.filePath})`);
1667
+ else ids.set(id, raw);
1668
+ if (raw.label.trim() === "") report("label must not be empty");
1669
+ if (raw.models.length === 0) report("models must list at least one model");
1670
+ const byLocal = /* @__PURE__ */ new Map();
1671
+ const models = [];
1672
+ for (const ref of raw.models) {
1673
+ const model = attempt(() => resolveModel(ref, raw, classes, "static models entry"), ref.line);
1674
+ if (!model) continue;
1675
+ if (models.includes(model)) {
1676
+ report(`models lists \`${model.className}\` more than once`, ref.line);
1677
+ continue;
1678
+ }
1679
+ models.push(model);
1680
+ byLocal.set(ref.local, model);
1681
+ byLocal.set(model.className, model);
1682
+ }
1683
+ const nav = [];
1684
+ for (const entry of raw.nav) {
1685
+ const model = attempt(() => resolveModel(entry.model, raw, classes, "static nav model"), entry.line);
1686
+ if (!model) continue;
1687
+ if (!models.includes(model)) {
1688
+ report(`nav entry \`${entry.label}\` points at \`${model.className}\`, which is not listed in models`, entry.line);
1689
+ continue;
1690
+ }
1691
+ nav.push({
1692
+ label: entry.label,
1693
+ model: model.className
1694
+ });
1695
+ }
1696
+ const requires = [];
1697
+ for (const required of raw.requires) if (required.length > RECIPE_ID_MAX_LENGTH || !RECIPE_ID_PATTERN.test(required)) report(`requires \`${required}\` is not a recipe id`);
1698
+ else if (required === id) report("a recipe cannot require itself");
1699
+ else if (requires.includes(required)) report(`requires lists \`${required}\` more than once`);
1700
+ else requires.push(required);
1701
+ let options;
1702
+ for (const entry of raw.options) {
1703
+ const model = byLocal.get(entry.key);
1704
+ if (!model) {
1705
+ report(`options.${entry.key} does not name one of the recipe's models`, entry.line);
1706
+ continue;
1707
+ }
1708
+ const read = readModelOptions(entry.value, model, classes, `options.${entry.key}`, (message) => report(message, entry.line));
1709
+ if (read) {
1710
+ options ??= {};
1711
+ if (options[model.className]) report(`options names \`${model.className}\` more than once`, entry.line);
1712
+ else options[model.className] = read;
1713
+ }
1714
+ }
1715
+ const help = raw.help ? attempt(() => readHelp(raw.help, raw), raw.help.line) : void 0;
1716
+ recipes.push({
1717
+ id,
1718
+ className: raw.className,
1719
+ label: raw.label,
1720
+ summary: raw.summary,
1721
+ synonyms: raw.synonyms,
1722
+ models: models.map((model) => model.className),
1723
+ nav,
1724
+ requires,
1725
+ ...options && Object.keys(options).length > 0 ? { options } : {},
1726
+ ...help ? { help } : {}
1727
+ });
1728
+ }
1729
+ const byId = new Map(recipes.map((recipe) => [recipe.id, recipe]));
1730
+ const reported = /* @__PURE__ */ new Set();
1731
+ const visit = (recipe, trail) => {
1732
+ if (trail.includes(recipe.id)) {
1733
+ const cycle = [...trail.slice(trail.indexOf(recipe.id)), recipe.id];
1734
+ const key = [...cycle].sort().join("|");
1735
+ if (!reported.has(key)) {
1736
+ reported.add(key);
1737
+ const raw = ids.get(recipe.id);
1738
+ errors.push({
1739
+ message: `Recipe ${recipe.className}: requires cycle ${cycle.join(" -> ")}`,
1740
+ filePath: raw?.filePath ?? "",
1741
+ line: raw?.line,
1742
+ severity: "error"
1743
+ });
1744
+ }
1745
+ return;
1746
+ }
1747
+ for (const required of recipe.requires) {
1748
+ const next = byId.get(required);
1749
+ if (next) visit(next, [...trail, recipe.id]);
1750
+ }
1751
+ };
1752
+ for (const recipe of recipes) visit(recipe, []);
1753
+ recipes.sort((a, b) => a.id.localeCompare(b.id));
1754
+ return {
1755
+ recipes,
1756
+ errors
1757
+ };
1758
+ }
1759
+ //#endregion
1079
1760
  //#region src/oxc-parser.ts
1080
1761
  function getLangFromFilename(filename) {
1081
1762
  if (filename.endsWith(".tsx")) return "tsx";
@@ -1099,6 +1780,7 @@ function parseFile(filePath) {
1099
1780
  let typeAliases = {};
1100
1781
  let smrtImports;
1101
1782
  let agentSurface;
1783
+ let recipes;
1102
1784
  try {
1103
1785
  const sourceText = readFileSync(filePath, "utf-8");
1104
1786
  const result = parseSync(filePath, sourceText, {
@@ -1131,6 +1813,15 @@ function parseFile(filePath) {
1131
1813
  }
1132
1814
  reportUnresolvedSpreads(ctx.unresolved, filePath, sourceText, errors);
1133
1815
  agentSurface = maybeExtractAgentSurface(program, sourceText, filePath);
1816
+ if (sourceMayDeclareRecipe(sourceText)) {
1817
+ const found = extractRecipes({
1818
+ body: program.body,
1819
+ sourceText,
1820
+ filePath
1821
+ });
1822
+ errors.push(...found.errors);
1823
+ if (found.recipes.length > 0) recipes = found.recipes;
1824
+ }
1134
1825
  }
1135
1826
  } catch (error) {
1136
1827
  errors.push({
@@ -1148,6 +1839,7 @@ function parseFile(filePath) {
1148
1839
  };
1149
1840
  if (smrtImports && smrtImports.size > 0) result2.smrtImports = smrtImports;
1150
1841
  if (agentSurface) result2.agentSurface = agentSurface;
1842
+ if (recipes) result2.recipes = recipes;
1151
1843
  return result2;
1152
1844
  }
1153
1845
  function maybeExtractAgentSurface(program, sourceText, filePath) {
@@ -1178,6 +1870,49 @@ function parseAgentSurfaceFile(filePath) {
1178
1870
  return;
1179
1871
  }
1180
1872
  }
1873
+ function parseRecipeFile(filePath) {
1874
+ let sourceText;
1875
+ try {
1876
+ sourceText = readFileSync(filePath, "utf-8");
1877
+ } catch (error) {
1878
+ return {
1879
+ recipes: [],
1880
+ errors: [{
1881
+ message: `Cannot read file for recipe discovery: ${error instanceof Error ? error.message : String(error)}`,
1882
+ filePath,
1883
+ severity: "error"
1884
+ }]
1885
+ };
1886
+ }
1887
+ if (!sourceMayDeclareRecipe(sourceText)) return {
1888
+ recipes: [],
1889
+ errors: []
1890
+ };
1891
+ try {
1892
+ const program = parseSync(filePath, sourceText, {
1893
+ lang: getLangFromFilename(filePath),
1894
+ preserveParens: false
1895
+ }).program;
1896
+ if (!program?.body) return {
1897
+ recipes: [],
1898
+ errors: []
1899
+ };
1900
+ return extractRecipes({
1901
+ body: program.body,
1902
+ sourceText,
1903
+ filePath
1904
+ });
1905
+ } catch (error) {
1906
+ return {
1907
+ recipes: [],
1908
+ errors: [{
1909
+ message: error instanceof Error ? error.message : String(error),
1910
+ filePath,
1911
+ severity: "error"
1912
+ }]
1913
+ };
1914
+ }
1915
+ }
1181
1916
  function parseSource(sourceText, filename = "test.ts") {
1182
1917
  const startTime = performance.now();
1183
1918
  const errors = [];
@@ -1185,6 +1920,7 @@ function parseSource(sourceText, filename = "test.ts") {
1185
1920
  let typeAliases = {};
1186
1921
  let smrtImports;
1187
1922
  let agentSurface;
1923
+ let recipes;
1188
1924
  try {
1189
1925
  const result = parseSync(filename, sourceText, {
1190
1926
  lang: getLangFromFilename(filename),
@@ -1216,6 +1952,15 @@ function parseSource(sourceText, filename = "test.ts") {
1216
1952
  }
1217
1953
  reportUnresolvedSpreads(ctx.unresolved, filename, sourceText, errors);
1218
1954
  agentSurface = maybeExtractAgentSurface(program, sourceText, filename);
1955
+ if (sourceMayDeclareRecipe(sourceText)) {
1956
+ const found = extractRecipes({
1957
+ body: program.body,
1958
+ sourceText,
1959
+ filePath: filename
1960
+ });
1961
+ errors.push(...found.errors);
1962
+ if (found.recipes.length > 0) recipes = found.recipes;
1963
+ }
1219
1964
  }
1220
1965
  } catch (error) {
1221
1966
  errors.push({
@@ -1233,6 +1978,7 @@ function parseSource(sourceText, filename = "test.ts") {
1233
1978
  };
1234
1979
  if (smrtImports && smrtImports.size > 0) result2.smrtImports = smrtImports;
1235
1980
  if (agentSurface) result2.agentSurface = agentSurface;
1981
+ if (recipes) result2.recipes = recipes;
1236
1982
  return result2;
1237
1983
  }
1238
1984
  var FORBIDDEN_OBJECT_KEYS = /* @__PURE__ */ new Set([
@@ -1301,22 +2047,39 @@ function extractTypeAliases(body) {
1301
2047
  const name = enumDecl.id?.name;
1302
2048
  const members = enumDecl.body?.members ?? enumDecl.members;
1303
2049
  if (name && isSafeObjectKey(name) && members && members.length > 0) {
1304
- const values = members.map((m) => {
1305
- if (m.initializer?.type === "Literal") {
1306
- const val = m.initializer.value;
1307
- if (typeof val === "string") return `'${val}'`;
1308
- if (typeof val === "number") return String(val);
1309
- }
1310
- return null;
1311
- }).filter((v) => v !== null);
1312
- if (values.length > 0) {
1313
- if (values.every((v) => v.startsWith("'"))) aliases[name] = values.join(" | ");
1314
- }
2050
+ const union = enumMembersToUnion(members);
2051
+ if (union) aliases[name] = union;
1315
2052
  }
1316
2053
  }
1317
2054
  }
1318
2055
  return aliases;
1319
2056
  }
2057
+ function enumMembersToUnion(members) {
2058
+ const values = [];
2059
+ let next = 0;
2060
+ for (const member of members) {
2061
+ const init = member.initializer;
2062
+ if (!init) {
2063
+ values.push(next);
2064
+ next += 1;
2065
+ } else if (init.type === "Literal" && typeof init.value === "string") values.push(init.value);
2066
+ else if (init.type === "Literal" && typeof init.value === "number") {
2067
+ values.push(init.value);
2068
+ next = init.value + 1;
2069
+ } else if (init.type === "UnaryExpression" && init.operator === "-" && init.argument?.type === "Literal" && typeof init.argument.value === "number") {
2070
+ values.push(-init.argument.value);
2071
+ next = -init.argument.value + 1;
2072
+ } else if (init.type === "TemplateLiteral" && init.expressions?.length === 0 && typeof init.quasis?.[0]?.value?.cooked === "string") values.push(init.quasis[0].value.cooked);
2073
+ else return null;
2074
+ }
2075
+ const allStrings = values.every((v) => typeof v === "string");
2076
+ const allNumbers = values.every((v) => typeof v === "number");
2077
+ if (!allStrings && !allNumbers) return null;
2078
+ return values.map((v) => {
2079
+ if (typeof v !== "string") return String(v);
2080
+ return `'${JSON.stringify(v).slice(1, -1).replaceAll("'", "\\'")}'`;
2081
+ }).join(" | ");
2082
+ }
1320
2083
  function unwrapTypeAssertion(node) {
1321
2084
  let current = node;
1322
2085
  for (let depth = 0; current && depth < 8; depth++) {
@@ -2170,15 +2933,38 @@ var ManifestAdapter = class ManifestAdapter {
2170
2933
  const manifestKey = definition.qualifiedName || definition.name.toLowerCase();
2171
2934
  objects[manifestKey] = definition;
2172
2935
  }
2936
+ const recipes = this.qualifyRecipes(options.recipes ?? [], options.packageName);
2937
+ const selectorEntries = Object.entries(options.uiSelectors ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
2938
+ const selectors = selectorEntries.length > 0 ? Object.fromEntries(selectorEntries.map(([id, decl]) => [id, {
2939
+ slotId: decl.slotId,
2940
+ selects: decl.selects,
2941
+ ...decl.label !== void 0 ? { label: decl.label } : {},
2942
+ ...decl.description !== void 0 ? { description: decl.description } : {}
2943
+ }])) : void 0;
2173
2944
  return {
2174
2945
  version: "1.0.0",
2175
2946
  timestamp: MANIFEST_TIMESTAMP,
2176
2947
  packageName: options.packageName,
2177
2948
  packageVersion: options.packageVersion,
2178
2949
  objects,
2179
- moduleType: "smrt"
2950
+ moduleType: "smrt",
2951
+ ...recipes.length > 0 ? { recipes } : {},
2952
+ ...selectors ? { uiSelectors: selectors } : {}
2180
2953
  };
2181
2954
  }
2955
+ /** Qualify recipe model names (`@scope/pkg:Class`) once the package is known. */
2956
+ qualifyRecipes(recipes, packageName) {
2957
+ const qualify = (className) => packageName ? createQualifiedName(packageName, className) : className;
2958
+ return recipes.map((recipe) => ({
2959
+ ...recipe,
2960
+ models: recipe.models.map(qualify),
2961
+ nav: recipe.nav.map((entry) => ({
2962
+ label: entry.label,
2963
+ model: qualify(entry.model)
2964
+ })),
2965
+ ...recipe.options ? { options: Object.fromEntries(Object.entries(recipe.options).map(([model, value]) => [qualify(model), value])) } : {}
2966
+ }));
2967
+ }
2182
2968
  /**
2183
2969
  * Convert a single resolved class definition to a `SmartObjectDefinition`.
2184
2970
  *
@@ -2329,6 +3115,8 @@ var ManifestAdapter = class ManifestAdapter {
2329
3115
  ...definition._meta,
2330
3116
  underlyingType: inference.underlyingType
2331
3117
  };
3118
+ const enumValues = this.resolveFieldEnum(field, definition.type);
3119
+ if (enumValues) definition.enum = enumValues;
2332
3120
  if (isFunctionType) definition.transient = true;
2333
3121
  if (fieldDecoratorOptions.transient === true) definition.transient = true;
2334
3122
  if (fieldDecoratorOptions.sensitive === true) definition.sensitive = true;
@@ -2725,6 +3513,19 @@ var ManifestAdapter = class ManifestAdapter {
2725
3513
  this._aliasDepth = (this._aliasDepth ?? 0) - 1;
2726
3514
  }
2727
3515
  }
3516
+ const closedSet = type ? resolveEnumValues(type, this.typeAliases) : void 0;
3517
+ if (closedSet?.every((v) => typeof v === "string")) return {
3518
+ type: "text",
3519
+ required: isRequired,
3520
+ defaultValue: this.parseDefaultValue(field.initializer, "string"),
3521
+ source: "annotation"
3522
+ };
3523
+ if (closedSet?.every((v) => typeof v === "number")) return {
3524
+ type: closedSet.every(Number.isInteger) ? "integer" : "decimal",
3525
+ required: isRequired,
3526
+ defaultValue: field.numericValue ?? void 0,
3527
+ source: "annotation"
3528
+ };
2728
3529
  if (field.initializer?.match(/^(['"]).*\1$/)) return {
2729
3530
  type: "text",
2730
3531
  required: isRequired,
@@ -2738,6 +3539,18 @@ var ManifestAdapter = class ManifestAdapter {
2738
3539
  };
2739
3540
  }
2740
3541
  /**
3542
+ * Allowed values of a field whose declared type is a closed literal set.
3543
+ * Only emitted when the final column type agrees with the value kind, so a
3544
+ * `@field({ type })` override can never contradict its own `enum`.
3545
+ */
3546
+ resolveFieldEnum(field, fieldType) {
3547
+ if (!field.typeAnnotation) return void 0;
3548
+ const values = resolveEnumValues(field.typeAnnotation, this.typeAliases);
3549
+ if (!values) return void 0;
3550
+ if (fieldType === "text") return values.every((v) => typeof v === "string") ? values : void 0;
3551
+ if (fieldType === "integer" || fieldType === "decimal") return values.every((v) => typeof v === "number") ? values : void 0;
3552
+ }
3553
+ /**
2741
3554
  * Parse default value from initializer string
2742
3555
  */
2743
3556
  parseDefaultValue(initializer, expectedType) {
@@ -2810,8 +3623,119 @@ var ManifestAdapter = class ManifestAdapter {
2810
3623
  }
2811
3624
  };
2812
3625
  //#endregion
3626
+ //#region src/ui-selectors.ts
3627
+ var QUALIFIED_NAME = /^@[^/\s:]+\/[^/\s:]+:[A-Za-z_$][\w$]*$/;
3628
+ function ordinal(a, b) {
3629
+ return a < b ? -1 : a > b ? 1 : 0;
3630
+ }
3631
+ function langOf(filePath) {
3632
+ if (filePath.endsWith(".tsx")) return "tsx";
3633
+ if (filePath.endsWith(".ts")) return "ts";
3634
+ if (filePath.endsWith(".jsx")) return "jsx";
3635
+ return "js";
3636
+ }
3637
+ function collect(value, fallbackId, filePath, out, errors, seen) {
3638
+ if (!value || typeof value !== "object" || seen.has(value)) return;
3639
+ seen.add(value);
3640
+ if (Array.isArray(value)) {
3641
+ for (const entry of value) collect(entry, void 0, filePath, out, errors, seen);
3642
+ return;
3643
+ }
3644
+ const record = value;
3645
+ if (Object.hasOwn(record, "selects")) {
3646
+ const slotId = typeof record.id === "string" && record.id !== "" ? record.id : fallbackId;
3647
+ if (typeof record.selects !== "string" || !QUALIFIED_NAME.test(record.selects)) errors.push({
3648
+ message: `UI slot "${slotId ?? "?"}" declares selects ${JSON.stringify(record.selects)}; it must be a string literal qualified model name ("@scope/package:ClassName").`,
3649
+ filePath,
3650
+ severity: "error"
3651
+ });
3652
+ else if (!slotId) errors.push({
3653
+ message: `A UI slot selecting ${record.selects} has no string \`id\`.`,
3654
+ filePath,
3655
+ severity: "error"
3656
+ });
3657
+ else out.push({
3658
+ slotId,
3659
+ selects: record.selects,
3660
+ ...typeof record.label === "string" ? { label: record.label } : {},
3661
+ ...typeof record.description === "string" ? { description: record.description } : {},
3662
+ filePath
3663
+ });
3664
+ }
3665
+ for (const [key, child] of Object.entries(record)) {
3666
+ if (key === "selects") continue;
3667
+ collect(child, key, filePath, out, errors, seen);
3668
+ }
3669
+ }
3670
+ function parseUiSelectorsFile(filePath) {
3671
+ const empty = {
3672
+ selectors: [],
3673
+ errors: []
3674
+ };
3675
+ let sourceText;
3676
+ try {
3677
+ sourceText = readFileSync(filePath, "utf-8");
3678
+ } catch {
3679
+ return empty;
3680
+ }
3681
+ if (!sourceText.includes("selects")) return empty;
3682
+ try {
3683
+ const program = parseSync(filePath, sourceText, {
3684
+ lang: langOf(filePath),
3685
+ preserveParens: false
3686
+ }).program;
3687
+ if (!program?.body) return empty;
3688
+ const constants = extractModuleObjectConstants(program.body, sourceText);
3689
+ const selectors = [];
3690
+ const errors = [];
3691
+ const seen = /* @__PURE__ */ new Set();
3692
+ for (const [name, constant] of constants) collect(constant.value, name, filePath, selectors, errors, seen);
3693
+ return {
3694
+ selectors,
3695
+ errors
3696
+ };
3697
+ } catch {
3698
+ return empty;
3699
+ }
3700
+ }
3701
+ function mergeUiSelectors(files, relativize = (p) => p) {
3702
+ const errors = files.flatMap((file) => file.errors);
3703
+ const all = files.flatMap((file) => file.selectors).sort((a, b) => ordinal(a.slotId, b.slotId) || ordinal(a.filePath, b.filePath));
3704
+ const selectors = {};
3705
+ const byModel = /* @__PURE__ */ new Map();
3706
+ for (const decl of all) {
3707
+ if (selectors[decl.slotId]) {
3708
+ errors.push({
3709
+ message: `UI slot id "${decl.slotId}" is declared more than once.`,
3710
+ filePath: decl.filePath,
3711
+ severity: "error"
3712
+ });
3713
+ continue;
3714
+ }
3715
+ const other = byModel.get(decl.selects);
3716
+ if (other) {
3717
+ errors.push({
3718
+ message: `UI slots "${other}" and "${decl.slotId}" both select ${decl.selects}; a model has one selector.`,
3719
+ filePath: decl.filePath,
3720
+ severity: "error"
3721
+ });
3722
+ continue;
3723
+ }
3724
+ byModel.set(decl.selects, decl.slotId);
3725
+ selectors[decl.slotId] = {
3726
+ ...decl,
3727
+ filePath: relativize(decl.filePath)
3728
+ };
3729
+ }
3730
+ return {
3731
+ selectors,
3732
+ errors
3733
+ };
3734
+ }
3735
+ //#endregion
2813
3736
  //#region src/scanner.ts
2814
3737
  var DEFAULT_INCLUDE = ["**/*.ts", "**/*.tsx"];
3738
+ var DEFAULT_UI_SELECTOR_INCLUDE = ["src/**/ui.ts"];
2815
3739
  var DEFAULT_SVELTE_INCLUDE = ["**/*.svelte"];
2816
3740
  var DEFAULT_AGENT_SURFACE_INCLUDE = [
2817
3741
  "**/*.ts",
@@ -2839,6 +3763,8 @@ var OxcScanner = class {
2839
3763
  options;
2840
3764
  resolver;
2841
3765
  scanResults = null;
3766
+ recipeErrors = [];
3767
+ outsideRecipes = [];
2842
3768
  /**
2843
3769
  * Create a new `OxcScanner` with the given options.
2844
3770
  *
@@ -2862,7 +3788,8 @@ var OxcScanner = class {
2862
3788
  followSymbolicLinks: options.followSymbolicLinks ?? false,
2863
3789
  agentSurface: options.agentSurface ?? true,
2864
3790
  svelteInclude: options.svelteInclude || DEFAULT_SVELTE_INCLUDE,
2865
- agentSurfaceInclude: options.agentSurfaceInclude || DEFAULT_AGENT_SURFACE_INCLUDE
3791
+ agentSurfaceInclude: options.agentSurfaceInclude || DEFAULT_AGENT_SURFACE_INCLUDE,
3792
+ uiSelectorInclude: options.uiSelectorInclude || DEFAULT_UI_SELECTOR_INCLUDE
2866
3793
  };
2867
3794
  this.resolver = new InheritanceResolver({
2868
3795
  baseClasses: this.options.baseClasses,
@@ -2899,7 +3826,8 @@ var OxcScanner = class {
2899
3826
  totalParseTimeMs: performance.now() - startTime,
2900
3827
  fileCount: files.length,
2901
3828
  typeAliases: {},
2902
- agentSurface: emptyAgentSurface()
3829
+ agentSurface: emptyAgentSurface(),
3830
+ recipes: []
2903
3831
  };
2904
3832
  const surfaces = [];
2905
3833
  for (const file of fileResults) {
@@ -2913,6 +3841,14 @@ var OxcScanner = class {
2913
3841
  surfaces.push(await this.scanSvelteDeclarations());
2914
3842
  results.agentSurface = mergeAgentSurfaces(surfaces, (filePath) => this.relativizeSourcePath(filePath));
2915
3843
  }
3844
+ const outside = await this.scanRecipesOutsideClassGlob(new Set(files));
3845
+ this.outsideRecipes = outside.recipes;
3846
+ results.errors.push(...outside.errors);
3847
+ const uiSelectors = await this.scanUiSelectors();
3848
+ if (uiSelectors) {
3849
+ results.uiSelectors = uiSelectors.selectors;
3850
+ results.errors.push(...uiSelectors.errors);
3851
+ }
2916
3852
  this.resolver.addClasses(results.classes);
2917
3853
  this.scanResults = results;
2918
3854
  return results;
@@ -2934,7 +3870,13 @@ var OxcScanner = class {
2934
3870
  */
2935
3871
  resolve() {
2936
3872
  if (!this.scanResults) throw new Error("Must call scan() before resolve()");
2937
- return this.resolver.resolveAll();
3873
+ const resolved = this.resolver.resolveAll();
3874
+ const { recipes, errors } = resolveRecipes([...this.scanResults.files.flatMap((file) => file.recipes ?? []), ...this.outsideRecipes], resolved);
3875
+ this.scanResults.recipes = recipes;
3876
+ this.scanResults.errors = this.scanResults.errors.filter((error) => !this.recipeErrors.includes(error));
3877
+ this.recipeErrors = errors;
3878
+ this.scanResults.errors.push(...errors);
3879
+ return resolved;
2938
3880
  }
2939
3881
  /**
2940
3882
  * Run both scan phases in a single call.
@@ -3092,6 +4034,59 @@ var OxcScanner = class {
3092
4034
  return surfaces;
3093
4035
  }
3094
4036
  /**
4037
+ * Find `SmrtRecipe` declarations in files the CLASS scan did not cover.
4038
+ *
4039
+ * Same rule as {@link scanDeclarationsOutsideClassGlob}: the model glob is
4040
+ * narrowed to where models live, a recipe need not live there, and a recipe
4041
+ * that vanished with no diagnostic is the failure to prevent.
4042
+ */
4043
+ async scanRecipesOutsideClassGlob(alreadyScanned) {
4044
+ const out = {
4045
+ recipes: [],
4046
+ errors: []
4047
+ };
4048
+ if (this.options.agentSurfaceInclude.length === 0) return out;
4049
+ let files;
4050
+ try {
4051
+ files = await discoverSourceFiles({
4052
+ cwd: this.options.cwd,
4053
+ include: this.options.agentSurfaceInclude,
4054
+ exclude: [...this.options.exclude, ...AGENT_SURFACE_PRUNE],
4055
+ followSymbolicLinks: this.options.followSymbolicLinks
4056
+ });
4057
+ } catch {
4058
+ return out;
4059
+ }
4060
+ for (const filePath of files) {
4061
+ if (alreadyScanned.has(filePath)) continue;
4062
+ if (!isAgentSurfaceSourcePath(filePath, this.options.cwd)) continue;
4063
+ const found = parseRecipeFile(filePath);
4064
+ out.recipes.push(...found.recipes);
4065
+ out.errors.push(...found.errors);
4066
+ }
4067
+ return out;
4068
+ }
4069
+ /**
4070
+ * Read selector-slot declarations (`selects`, #3599) from the package's
4071
+ * `ui.ts` files, independently of the class-scan glob.
4072
+ */
4073
+ async scanUiSelectors() {
4074
+ if (this.options.uiSelectorInclude.length === 0) return void 0;
4075
+ let files;
4076
+ try {
4077
+ files = await discoverSourceFiles({
4078
+ cwd: this.options.cwd,
4079
+ include: this.options.uiSelectorInclude,
4080
+ exclude: [...this.options.exclude, ...AGENT_SURFACE_PRUNE],
4081
+ followSymbolicLinks: this.options.followSymbolicLinks
4082
+ });
4083
+ } catch {
4084
+ return;
4085
+ }
4086
+ const merged = mergeUiSelectors(files.sort().map((file) => parseUiSelectorsFile(file)), (filePath) => this.relativizeSourcePath(filePath));
4087
+ return Object.keys(merged.selectors).length > 0 || merged.errors.length > 0 ? merged : void 0;
4088
+ }
4089
+ /**
3095
4090
  * Search `.svelte` files for declarations the scanner can never read.
3096
4091
  *
3097
4092
  * A `.svelte` file is not a TypeScript program and is never parsed here, so
@@ -3141,6 +4136,6 @@ var OxcScanner = class {
3141
4136
  }
3142
4137
  };
3143
4138
  //#endregion
3144
- export { scanSvelteAgentSurface as _, parseFile as a, discoverSourceFiles as c, checkAgentSurfaceToolNames as d, emptyAgentSurface as f, mergeAgentSurfaces as g, isPrunedAgentSurfacePath as h, parseAgentSurfaceFile as i, normalizeGlobSeparators as l, isAgentSurfaceSourcePath as m, ManifestAdapter as n, parseSource as o, extractAgentSurface as p, extractSmrtImports as r, InheritanceResolver as s, OxcScanner as t, relativeGlobToCwd as u, sourceMayDeclareAgentSurface as v };
4139
+ export { mergeAgentSurfaces as C, isPrunedAgentSurfacePath as S, sourceMayDeclareAgentSurface as T, relativeGlobToCwd as _, parseAgentSurfaceFile as a, extractAgentSurface as b, parseSource as c, resolveRecipes as d, sourceMayDeclareRecipe as f, normalizeGlobSeparators as g, discoverSourceFiles as h, extractSmrtImports as i, deriveHelpFieldRefs as l, resolveEnumValues as m, parseUiSelectorsFile as n, parseFile as o, InheritanceResolver as p, ManifestAdapter as r, parseRecipeFile as s, OxcScanner as t, extractRecipes as u, checkAgentSurfaceToolNames as v, scanSvelteAgentSurface as w, isAgentSurfaceSourcePath as x, emptyAgentSurface as y };
3145
4140
 
3146
- //# sourceMappingURL=scanner-ItT4WTxN.js.map
4141
+ //# sourceMappingURL=scanner-CeTWAIar.js.map