@tinkerlandai/indiana-iam 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analyze.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { execFileSync } from "node:child_process";
3
+ import { automaticSourceAnchors, anchorAttributes, sourceScriptKind } from "./anchors.mjs";
4
+ import { sourceFiles } from "./source-files.mjs";
4
5
  import { dependency } from "./dependencies.mjs";
5
6
  import { assembleRouteTree, readVariables } from "./route-tree.mjs";
7
+ import { sourceEntry } from "./source-entry.mjs";
6
8
  import { frameworkRoutes, externalImport } from "./framework-routes.mjs";
9
+ import { sourceResolution } from "./source-resolution.mjs";
7
10
  import { routeIndexDigest, refreshRenderHashes } from "./render-index.mjs";
8
11
  import { storeDocumentNodes } from "./node-hashes.mjs";
9
12
  import { staticQuery } from "./static-query.mjs";
@@ -26,6 +29,28 @@ const { JSDOM } = dependency("jsdom").value;
26
29
  const UNKNOWN = Symbol("unknown");
27
30
  import { sensitiveName, redactLiteral, privateUrl } from "./privacy.mjs";
28
31
  const isUnknown = (value) => value === UNKNOWN;
32
+ // React numeric styles use px except for unitless properties and CSS variables.
33
+ const unitlessStyles = new Set("animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth".split(" "));
34
+ function staticStyle(value, unsupported) {
35
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
36
+ unsupported();
37
+ return "";
38
+ }
39
+ return Object.entries(value).flatMap(([key, raw]) => {
40
+ if (raw == null || typeof raw === "boolean" || raw === "") return [];
41
+ if (!/^(?:[a-zA-Z][a-zA-Z0-9]*|--[a-zA-Z0-9_-]+)$/.test(key) ||
42
+ !["string", "number"].includes(typeof raw) ||
43
+ (typeof raw === "number" && !Number.isFinite(raw)) ||
44
+ /[;{}<>\\]|url\s*\(|expression\s*\(|@import/i.test(String(raw))) {
45
+ unsupported();
46
+ return [];
47
+ }
48
+ const name = key.startsWith("--") ? key : key.replace(/[A-Z]/g, letter => "-" + letter.toLowerCase()).replace(/^ms-/, "-ms-");
49
+ const unprefixed = key.replace(/^(?:Webkit|Moz|ms|O)(?=[A-Z])/, "").replace(/^[A-Z]/, letter => letter.toLowerCase());
50
+ const unit = typeof raw === "number" && raw !== 0 && !key.startsWith("--") && !unitlessStyles.has(unprefixed) ? "px" : "";
51
+ return [name + ":" + String(raw).trim() + unit];
52
+ }).join(";");
53
+ }
29
54
  const allowedTags = new Set(
30
55
  "main section article aside header footer nav div span p h1 h2 h3 h4 h5 h6 a button form label input select option textarea ul ol li dl dt dd table thead tbody tr th td caption fieldset legend details summary pre code blockquote strong em b i small s u mark time br hr img picture source svg path circle rect line polyline polygon ellipse g title".split(
31
56
  " ",
@@ -117,7 +142,8 @@ function functionFrom(node) {
117
142
  if (
118
143
  ts.isFunctionDeclaration(node) ||
119
144
  ts.isArrowFunction(node) ||
120
- ts.isFunctionExpression(node)
145
+ ts.isFunctionExpression(node) ||
146
+ ts.isMethodDeclaration(node)
121
147
  )
122
148
  return node;
123
149
  if (ts.isCallExpression(node))
@@ -133,52 +159,11 @@ export async function analyze({
133
159
  maxFiles = 5000,
134
160
  maxBytes = 100_000_000,
135
161
  navigationPaths = [],
162
+ entry,
163
+ automaticAnchors = false,
136
164
  }) {
137
165
  root = await fs.realpath(root);
138
- let files;
139
- try {
140
- files = execFileSync("git", ["-C", root, "ls-files", "-z"], {
141
- encoding: "utf8",
142
- maxBuffer: 8_000_000,
143
- stdio: ["ignore", "pipe", "pipe"],
144
- })
145
- .split("\0")
146
- .filter(Boolean);
147
- } catch {
148
- files = [];
149
- }
150
- // An authorized copied app can sit inside another repository's ignored
151
- // verification directory. git succeeds there with an empty index; it must
152
- // not silently turn a complete application into a zero-route manifest.
153
- if (!files.length) {
154
- async function walk(dir) {
155
- for (const item of await fs.readdir(dir, { withFileTypes: true })) {
156
- if (
157
- item.name.startsWith(".") ||
158
- ["node_modules", "dist", "build", "coverage"].includes(item.name)
159
- )
160
- continue;
161
- const p = path.join(dir, item.name);
162
- if (item.isDirectory()) await walk(p);
163
- else if (item.isFile())
164
- files.push(path.relative(root, p).split(path.sep).join("/"));
165
- }
166
- }
167
- await walk(root);
168
- }
169
- files = files
170
- .filter(
171
- (file) =>
172
- /\.(?:[cm]?[jt]sx?|css|mdx|json)$/.test(file) &&
173
- !/(^|\/)(?:node_modules|\.next|\.git|\.indiana|api|server|secrets?)(\/|$)/.test(
174
- file,
175
- ) &&
176
- !/(?:^|\/)(?:\.?env|credentials|secrets)(?:\.|\/)/i.test(file) &&
177
- !file.startsWith('public/.well-known/indiana/')&&
178
- !/(?:lock|package-lock)\.json$/.test(file),
179
- )
180
- .sort();
181
- if (files.length > maxFiles) throw new Error("Source file budget exceeded");
166
+ const files = await sourceFiles(root, { maxFiles });
182
167
  const modules = new Map(),
183
168
  inventory = [],
184
169
  gaps = [],
@@ -232,12 +217,13 @@ export async function analyze({
232
217
  text,
233
218
  ts.ScriptTarget.Latest,
234
219
  true,
235
- /\.jsx?$/.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX,
220
+ sourceScriptKind(file, ts),
236
221
  );
237
222
  const module = {
238
223
  file,
239
224
  text,
240
225
  source,
226
+ anchors: automaticAnchors ? automaticSourceAnchors(file, source) : new Map(),
241
227
  bindings: new Map(),
242
228
  imports: new Map(),
243
229
  exports: new Map(),
@@ -268,6 +254,7 @@ export async function analyze({
268
254
  }
269
255
  if (ts.isFunctionDeclaration(statement) && (statement.name||statement.modifiers?.some(m=>m.kind===ts.SyntaxKind.DefaultKeyword)))
270
256
  module.bindings.set(statement.name?.text||'$default', statement);
257
+ if (ts.isEnumDeclaration(statement)) module.bindings.set(statement.name.text, statement);
271
258
  if (ts.isVariableStatement(statement))
272
259
  for (const d of statement.declarationList.declarations)
273
260
  if (ts.isIdentifier(d.name) && d.initializer)
@@ -366,10 +353,9 @@ export async function analyze({
366
353
  return binding(target,key,seen);
367
354
  }
368
355
  const componentDefs = new Map();
369
- for (const module of modules.values())
370
- for (const [symbol, node] of module.bindings) {
356
+ function registerComponent(module, symbol, node) {
371
357
  const fn = functionFrom(node);
372
- if (!fn) continue;
358
+ if (!fn) return null;
373
359
  let hasJsx = false;
374
360
  function visit(n) {
375
361
  if (
@@ -381,7 +367,7 @@ export async function analyze({
381
367
  ts.forEachChild(n, visit);
382
368
  }
383
369
  visit(fn);
384
- if (!hasJsx) continue;
370
+ if (!hasJsx) return null;
385
371
  const key = identity("component", { file: module.file, symbol }),
386
372
  ordinals = new Map();
387
373
  let ordinal = 0;
@@ -421,12 +407,21 @@ export async function analyze({
421
407
  source: loc,
422
408
  dependencies: [],
423
409
  });
424
- }
425
- for (const def of componentDefs.values()) {
410
+ return def;
411
+ }
412
+ for (const module of modules.values())
413
+ for (const [symbol, node] of module.bindings) registerComponent(module, symbol, node);
414
+ const resolution = sourceResolution({ ts, resolveModule, resolveBinding: binding });
415
+ const componentBinding = (module, name) => {
416
+ const ref = resolution.component(module, name);
417
+ if (ref?.wrapped) gap("LAZY_WRAPPER_UNOBSERVED", module.file, name + ": only the statically forwarded successful module is projected; retry/reload behavior is not executed");
418
+ return ref;
419
+ };
420
+ function collectComponentDependencies(def) {
426
421
  function collectDependencies(node) {
427
422
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
428
423
  const opening = ts.isJsxElement(node) ? node.openingElement : node,
429
- ref = binding(def.module, opening.tagName.getText(def.module.source)),
424
+ ref = componentBinding(def.module, opening.tagName.getText(def.module.source)),
430
425
  target = ref && componentDefs.get(ref.module.file + "#" + ref.name);
431
426
  if (
432
427
  target &&
@@ -438,6 +433,20 @@ export async function analyze({
438
433
  }
439
434
  collectDependencies(def.fn);
440
435
  }
436
+ for (const def of componentDefs.values()) collectComponentDependencies(def);
437
+ const inlineNodes = new WeakMap(), inlineCounts = new Map();
438
+ function inlineComponent(module, node, route) {
439
+ if (inlineNodes.has(node)) return inlineNodes.get(node);
440
+ const group = module.file + "#" + route, occurrence = inlineCounts.get(group) ?? 0;
441
+ inlineCounts.set(group, occurrence + 1);
442
+ const symbol = "$route_" + hashObject("iam/inline-route-component/v1", { route, occurrence }).slice(0, 24);
443
+ module.bindings.set(symbol, node);
444
+ const def = registerComponent(module, symbol, node);
445
+ if (def) collectComponentDependencies(def);
446
+ const target = { file: module.file, symbol };
447
+ inlineNodes.set(node, target);
448
+ return target;
449
+ }
441
450
  function sourceLocation(module, node, symbol) {
442
451
  const loc = module.source.getLineAndCharacterOfPosition(
443
452
  node.getStart(module.source),
@@ -725,11 +734,16 @@ export async function analyze({
725
734
  Object.hasOwn(Object(value), "stateInitial") &&
726
735
  d.name.elements[0]
727
736
  ) {
728
- const key = d.name.elements[0].name?.text;
737
+ const stateBinding = d.name.elements[0];
738
+ if (!ts.isBindingElement(stateBinding) || stateBinding.dotDotDotToken || !ts.isIdentifier(stateBinding.name)) {
739
+ gap("STATE_BINDING_UNSUPPORTED", def.module.file, "Omitted, nested or rest state bindings have no named scalar value for UI simulation");
740
+ continue;
741
+ }
742
+ const key = stateBinding.name.text;
729
743
  ctx[key] = sensitiveName(key) ? UNKNOWN : value.stateInitial;
730
744
  ctx.__iamStates.add(key);
731
745
  ctx.__iamStateNodes[key] = d;
732
- if (d.name.elements[1]?.name)
746
+ if (d.name.elements[1]?.name && !d.name.elements[1].dotDotDotToken && ts.isIdentifier(d.name.elements[1].name))
733
747
  ctx.__iamSetters[d.name.elements[1].name.text] = key;
734
748
  }
735
749
  }
@@ -786,7 +800,10 @@ export async function analyze({
786
800
  !/^\/(?:[^/?#[\]\\\s]+\/)*[^/?#[\]\\\s]+$/.test(routing.basePath)
787
801
  )
788
802
  throw new Error("Invalid source basePath");
789
- const compositions = frameworkRoutes({ modules, resolveModule, ts, sourceLocation, navigationPaths, gap });
803
+ const selection = await sourceEntry({ root, modules, resolveModule, ts, entry });
804
+ if (selection.htmlSource) inventory.push(selection.htmlSource);
805
+ inventory.sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
806
+ const compositions = frameworkRoutes({ modules, ...selection, resolveModule, ts, sourceLocation, navigationPaths, gap, resolution, inlineComponent });
790
807
  const pageFiles = compositions.pageFiles;
791
808
  routing.framework = compositions.framework;
792
809
  if (compositions.routeSources) routing.routeSources = compositions.routeSources;
@@ -945,6 +962,20 @@ export async function analyze({
945
962
  const value = evalExpression(node, owner.module, ctx);
946
963
  return ["string", "boolean", "number"].includes(typeof value) ? { op: "const", value } : null;
947
964
  }
965
+ function uiExpressionType(expression, payload = {}) {
966
+ if (!expression) return undefined;
967
+ if (expression.op === "const") return typeof expression.value;
968
+ if (expression.op === "var") return ui.variables.find(variable => variable.variableKey === expression.key)?.valueType;
969
+ if (expression.op === "event") return payload[expression.key];
970
+ const type = child => uiExpressionType(child, payload);
971
+ if (expression.op === "not") return type(expression.arg) ? "boolean" : undefined;
972
+ if (expression.op === "classes") return expression.parts.every(part => type(part.when) === "boolean") ? "string" : undefined;
973
+ if (["all", "any"].includes(expression.op)) return expression.args.every(child => type(child) === "boolean") ? "boolean" : undefined;
974
+ const left = type(expression.left), right = type(expression.right);
975
+ if (["eq", "ne"].includes(expression.op)) return left && left === right ? "boolean" : undefined;
976
+ if (["add", "subtract"].includes(expression.op)) return left === "number" && right === "number" ? "number" : undefined;
977
+ return undefined;
978
+ }
948
979
  function uiHandler(initializer, owner, ctx) {
949
980
  let fn = initializer && ts.isJsxExpression(initializer) ? unwrap(initializer.expression) : null;
950
981
  if (fn && ts.isIdentifier(fn) && ctx.__iamPropHandlers.has(fn.text)) return ctx.__iamPropHandlers.get(fn.text);
@@ -966,8 +997,18 @@ export async function analyze({
966
997
  const payload = {};
967
998
  function eventInputs(e) { if (e.op === "event") payload[e.key] = e.key === "checked" ? "boolean" : "string"; for (const child of [e.arg, e.left, e.right, ...(e.args || []),...(e.parts||[]).map(part=>part.when)]) if (child) eventInputs(child); }
968
999
  eventInputs(value);
1000
+ if (uiExpressionType(value, payload) !== ui.variables.find(variable => variable.variableKey === variableKey)?.valueType) return null;
969
1001
  return { variableKey, value, payload };
970
1002
  }
1003
+ function unobservedValue(node, owner, instance) {
1004
+ const source = sourceLocation(owner.module, node, owner.symbol);
1005
+ const elementKey = identity("element", { component: owner.key, unobservedValue: node.getStart(owner.module.source) - owner.fn.getStart(owner.module.source) });
1006
+ const nodeId = identity("node", { page: pageKey, element: elementKey, instance });
1007
+ elements.set(elementKey, { elementKey, componentKey: owner.key, tag: "span", source, identityStrategy: "provisional_ast_ordinal", editableProperties: [], binding: "preview_only" });
1008
+ record.set(nodeId, { elementKey, source, synthetic: true });
1009
+ // Preserve an unknown child boundary. An empty projection must never prove that replacing text is safe.
1010
+ return '<span data-iam-node="' + nodeId + '" hidden=""></span>';
1011
+ }
971
1012
  function renderExpr(node, owner, ctx, instance, depth) {
972
1013
  node = unwrap(node);
973
1014
  if (!node) return "";
@@ -1117,7 +1158,7 @@ export async function analyze({
1117
1158
  .join("");
1118
1159
  }
1119
1160
  gap("DYNAMIC_COLLECTION", m.file, node.expression.getText(m.source));
1120
- return "";
1161
+ return unobservedValue(node, owner, instance);
1121
1162
  }
1122
1163
  if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) {
1123
1164
  if (
@@ -1141,18 +1182,25 @@ export async function analyze({
1141
1182
  typeof value !== "boolean"
1142
1183
  )
1143
1184
  return escape(value);
1144
- if (isUnknown(value))
1185
+ if (isUnknown(value)) {
1145
1186
  gap("DYNAMIC_VALUE", m.file, node.getText(m.source).slice(0, 100));
1187
+ return unobservedValue(node, owner, instance);
1188
+ }
1146
1189
  return "";
1147
1190
  }
1148
1191
  if (++renderedCount > 12000)
1149
1192
  throw new Error("Rendered node budget exceeded");
1150
1193
  const opening = ts.isJsxElement(node) ? node.openingElement : node,
1151
1194
  tag = opening.tagName.getText(m.source),
1152
- props = {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
1195
+ props = m.anchors.get(opening) ? { "data-indiana-id": m.anchors.get(opening) } : {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
1153
1196
  for (const attr of opening.attributes.properties) {
1154
1197
  if (ts.isJsxSpreadAttribute(attr)) {
1155
1198
  const v = evalExpression(attr.expression, m, ctx);
1199
+ if (isUnknown(v)) {
1200
+ for (const attribute of anchorAttributes) delete props[attribute];
1201
+ gap("UNKNOWN_ANCHOR_SPREAD", m.file, "Opaque spread may override element identity");
1202
+ continue;
1203
+ }
1156
1204
  if (v && typeof v === "object") {
1157
1205
  Object.assign(props, v);
1158
1206
  const inherited = forwarded.get(v);
@@ -1252,7 +1300,7 @@ export async function analyze({
1252
1300
  : props.children?.iamChildren || "";
1253
1301
  if (ctx[tag]?.iamChildren !== undefined) return ctx[tag].iamChildren;
1254
1302
  const routerPrimitive = externalImport(m, tag);
1255
- if (["react-router", "react-router-dom"].includes(routerPrimitive?.specifier) && routerPrimitive.name === "Outlet") return ctx.__iamOutlet?.iamChildren ?? "";
1303
+ if (["react-router", "react-router-dom", "@tanstack/react-router"].includes(routerPrimitive?.specifier) && routerPrimitive.name === "Outlet") return ctx.__iamOutlet?.iamChildren ?? "";
1256
1304
  if (
1257
1305
  /^(?:ThemeProvider|Providers?|Analytics|TailwindIndicator|Toaster)$/.test(
1258
1306
  tag,
@@ -1267,7 +1315,7 @@ export async function analyze({
1267
1315
  props["data-iam-adapter"] = tag;
1268
1316
  }
1269
1317
  if (/^[A-Z]/.test(tag) && !intrinsicAlias) {
1270
- if (tag === "Link" || ["react-router", "react-router-dom"].includes(routerPrimitive?.specifier) && ["Link", "NavLink"].includes(routerPrimitive.name)) {
1318
+ if (tag === "Link" || ["react-router", "react-router-dom", "@tanstack/react-router"].includes(routerPrimitive?.specifier) && ["Link", "NavLink"].includes(routerPrimitive.name)) {
1271
1319
  htmlTag = "a";
1272
1320
  if (typeof props.to === "string") props.href = props.to;
1273
1321
  }
@@ -1286,7 +1334,7 @@ export async function analyze({
1286
1334
  .filter((v) => typeof v === "string")
1287
1335
  .join(" ");
1288
1336
  } else {
1289
- const ref = binding(m, tag),
1337
+ const ref = componentBinding(m, tag),
1290
1338
  target = ref && componentDefs.get(ref.module.file + "#" + ref.name);
1291
1339
  if (target) {
1292
1340
  const own = components.get(owner.key);
@@ -1313,40 +1361,30 @@ export async function analyze({
1313
1361
  }
1314
1362
  }
1315
1363
  if (!allowedTags.has(htmlTag)) return children;
1316
- const anchor = ["data-indiana-id", "data-testid", "id", "name"]
1364
+ const anchor = anchorAttributes
1317
1365
  .map((key) =>
1318
1366
  typeof props[key] === "string" ? key + "=" + props[key] : null,
1319
1367
  )
1320
1368
  .find(Boolean);
1321
1369
  const ordinal = owner.ordinals.get(node) ?? 0,
1322
- explicitAnchor = anchor && !anchor.startsWith('name='),
1323
- elementKey = identity("element", {
1370
+ explicitAnchor = Boolean(anchor);
1371
+ let elementKey = identity("element", {
1324
1372
  component: owner.key,
1325
1373
  tag,
1326
1374
  anchor: explicitAnchor ? anchor : "jsx:" + ordinal,
1327
1375
  });
1376
+ const source = sourceLocation(m, node, owner.symbol);
1377
+ const prior = elements.get(elementKey);
1378
+ if (prior && (prior.source.file !== source.file || prior.source.line !== source.line || prior.source.column !== source.column)) {
1379
+ // Keep both concrete source sites. The DOM uniqueness check makes duplicate anchors read-only.
1380
+ elementKey = identity("element", { component: owner.key, tag, anchor, ordinal });
1381
+ gap("AMBIGUOUS_ELEMENT_ANCHOR", m.file, tag);
1382
+ }
1328
1383
  const nodeId = identity("node", {
1329
1384
  page: pageKey,
1330
1385
  element: elementKey,
1331
1386
  instance,
1332
- }),
1333
- source = sourceLocation(m, node, owner.symbol);
1334
- const prior = elements.get(elementKey);
1335
- if (
1336
- explicitAnchor &&
1337
- prior &&
1338
- (prior.source.file !== source.file ||
1339
- prior.source.line !== source.line ||
1340
- prior.source.column !== source.column)
1341
- )
1342
- throw new Error(
1343
- "Ambiguous explicit element anchor " +
1344
- anchor +
1345
- " in " +
1346
- m.file +
1347
- ":" +
1348
- source.line,
1349
- );
1387
+ });
1350
1388
  elements.set(elementKey, {
1351
1389
  elementKey,
1352
1390
  componentKey: owner.key,
@@ -1369,6 +1407,11 @@ export async function analyze({
1369
1407
  }
1370
1408
  for (const binding of uiBindings.filter(b => ["value", "checked", "disabled", "className"].includes(b.property))) {
1371
1409
  if(['value','checked'].includes(binding.property)&&!['input','select','textarea'].includes(htmlTag)){gap('UI_BINDING_TARGET_UNSUPPORTED',m.file,tag+': input property cannot be simulated on a non-input adapter');continue;}
1410
+ const expectedType = ["checked", "disabled"].includes(binding.property) ? "boolean" : "string";
1411
+ if (uiExpressionType(binding.value) !== expectedType) {
1412
+ gap("UI_BINDING_TYPE_UNSUPPORTED", m.file, tag + "." + binding.property + ": source value cannot be represented by the typed UI simulation");
1413
+ continue;
1414
+ }
1372
1415
  ui.bindings.push({ bindingKey: identity("ui-binding", { node: nodeId, property: binding.property }), routeKey: pageKey, nodeId, elementKey, ...binding });
1373
1416
  }
1374
1417
  const sensitiveInput = htmlTag === "input" && (["password", "hidden"].includes(String(props.type).toLowerCase()) || sensitiveName(String(props.name || props.id || "")));
@@ -1380,6 +1423,12 @@ export async function analyze({
1380
1423
  }
1381
1424
  const attrs = { "data-iam-node": nodeId };
1382
1425
  for (const [key, value] of Object.entries(props)) {
1426
+ if (key === "style") {
1427
+ const css = staticStyle(value, () => gap("INLINE_STYLE_UNSUPPORTED", m.file,
1428
+ "Inline style at line " + source.line + ": only static CSS values without resource URLs are reconstructed"));
1429
+ if (css) attrs.style = css;
1430
+ continue;
1431
+ }
1383
1432
  if (isUnknown(value) || value == null || typeof value === "object")
1384
1433
  continue;
1385
1434
  if (["href", "src"].includes(key) && privateUrl(value)) {
@@ -1400,7 +1449,7 @@ export async function analyze({
1400
1449
  clipRule: "clip-rule",
1401
1450
  }[key] || key;
1402
1451
  if (
1403
- !/^(?:class|id|name|type|placeholder|value|title|alt|href|src|role|for|disabled|hidden|checked|width|height|viewBox|d|fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|fill-rule|clip-rule|cx|cy|r|x|y|x1|x2|y1|y2|points|aria-[a-z-]+|data-indiana-id|data-testid|data-iam-(?:adapter|toggle))$/.test(
1452
+ !/^(?:class|id|name|type|placeholder|value|title|alt|href|src|role|for|disabled|hidden|checked|width|height|viewBox|d|fill|stroke|stroke-width|stroke-linecap|stroke-linejoin|fill-rule|clip-rule|cx|cy|r|x|y|x1|x2|y1|y2|points|aria-[a-z-]+|data-indiana-id|data-indiana-map-(?:area|flow|kind|auth|edge)|data-testid|data-test|data-qa|data-iam-(?:adapter|toggle))$/.test(
1404
1453
  attr,
1405
1454
  )
1406
1455
  )
@@ -1442,16 +1491,24 @@ export async function analyze({
1442
1491
  );
1443
1492
  }
1444
1493
  function renderEntry(file, props, instance, symbol) {
1445
- const entryModule = modules.get(file), entryRef = entryModule && binding(entryModule, symbol ?? entryModule.defaultName);
1494
+ const entryModule = modules.get(file), entryRef = entryModule && componentBinding(entryModule, symbol ?? entryModule.defaultName);
1446
1495
  const entryDef = entryRef && componentDefs.get(entryRef.module.file + "#" + entryRef.name);
1447
- if (!entryDef) { gap("PAGE_NO_JSX", file, "Composition entry has no inspectable JSX; null defaults are inert"); return ""; }
1496
+ if (!entryDef) {
1497
+ if (entryRef) {
1498
+ const key = identity("component", { file: entryRef.module.file, symbol: entryRef.name });
1499
+ if (!components.has(key)) components.set(key, { componentKey: key, file: entryRef.module.file, name: entryRef.name, source: sourceLocation(entryRef.module, entryRef.node, entryRef.name), dependencies: [] });
1500
+ }
1501
+ gap("PAGE_NO_JSX", file, "Composition entry has no inspectable JSX; null defaults are inert");
1502
+ return "";
1503
+ }
1448
1504
  return renderComponent(entryDef, props, instance, 0);
1449
1505
  }
1450
1506
  function renderBranch(branch) {
1451
1507
  let content = renderEntry(branch.file, {}, branch === composition.composition ? "page" : "slot-page:" + branch.file, branch.symbol);
1452
1508
  for (const layer of branch.layers) {
1453
- const props = { children: { iamChildren: content } };
1454
- if (routing.framework === "react-router") props.__iamOutlet = { iamChildren: content };
1509
+ const props = ["react-router", "tanstack-router"].includes(routing.framework)
1510
+ ? { __iamOutlet: { iamChildren: content } }
1511
+ : { children: { iamChildren: content } };
1455
1512
  if (/^(?:src\/)?pages\/_app\.[jt]sx?$/.test(layer.file)) { props.Component = { iamChildren: content }; props.pageProps = {}; }
1456
1513
  for (const slot of layer.slots) {
1457
1514
  props[slot.name] = { iamChildren: renderBranch(slot.branch) };
@@ -1487,6 +1544,27 @@ export async function analyze({
1487
1544
  const dom = new JSDOM(
1488
1545
  "<!doctype html><html><body>" + html + "</body></html>",
1489
1546
  );
1547
+ // JSX children may be forwarded more than once, and HTML repair may clone
1548
+ // an invalid nested anchor. Each concrete occurrence needs a unique node;
1549
+ // ambiguous copies remain preview-only and have no simulated state bridge.
1550
+ const occurrences = new Map();
1551
+ for (const element of dom.window.document.querySelectorAll("[data-iam-node]")) {
1552
+ const id = element.getAttribute("data-iam-node");
1553
+ const matches = occurrences.get(id) ?? [];
1554
+ matches.push(element);
1555
+ occurrences.set(id, matches);
1556
+ }
1557
+ for (const [original, matches] of occurrences) {
1558
+ if (matches.length < 2) continue;
1559
+ const rec = record.get(original);
1560
+ if (!rec) throw Error("Unknown repeated source node");
1561
+ gap("REPEATED_SOURCE_INSTANCE", rec.source.file, "Repeated forwarded children or HTML repair: copies are preview-only without a state bridge");
1562
+ for (const [index, element] of matches.entries()) {
1563
+ const id = identity("node", { page: pageKey, original, occurrence: index });
1564
+ element.setAttribute("data-iam-node", id);
1565
+ record.set(id, { ...rec, synthetic: true });
1566
+ }
1567
+ }
1490
1568
  const nodes = [];
1491
1569
  function collect(node, parentId, index) {
1492
1570
  if (node.nodeType !== 1 && node.nodeType !== 3) return null;
@@ -1712,7 +1790,7 @@ export async function analyze({
1712
1790
  schemaVersion: 2,
1713
1791
  producer: {
1714
1792
  name: "indiana-app-manifest-local",
1715
- version: "0.16.0",
1793
+ version: "0.19.0",
1716
1794
  method: "typescript-ast-and-bounded-static-projection",
1717
1795
  },
1718
1796
  app: { name, platforms: ["web"] },
@@ -0,0 +1,127 @@
1
+ import { dependency } from "./dependencies.mjs";
2
+ import { sha256 } from "./integrity.mjs";
3
+ import { redactLiteral } from "./privacy.mjs";
4
+
5
+ export const anchorAttributes = [
6
+ "id",
7
+ "data-testid",
8
+ "data-test",
9
+ "data-qa",
10
+ "data-indiana-id",
11
+ ];
12
+ export function sourceScriptKind(file, ts) {
13
+ return /\.[cm]?ts$/.test(file)
14
+ ? ts.ScriptKind.TS
15
+ : /\.tsx$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
16
+ }
17
+ export function instrumentableSource(file, text) {
18
+ return (
19
+ typeof text === "string" &&
20
+ Buffer.byteLength(text) <= 1_000_000 &&
21
+ /\.[cm]?[jt]sx?$/.test(file) &&
22
+ !/(?:^|\/)(?:node_modules|\.next|\.git|\.indiana|api|server|secrets?)(?:\/|$)/.test(
23
+ file,
24
+ )
25
+ );
26
+ }
27
+ export const anchorSelectorPattern =
28
+ /^[a-z][a-z0-9-]*\[(?:id|data-testid|data-test|data-qa|data-indiana-id)="(?:[^"\\\x00-\x1f\x7f]|\\["\\])*"\]$/;
29
+
30
+ export function anchorSelector(node) {
31
+ if (node.kind !== "element") return null;
32
+ for (const attribute of anchorAttributes) {
33
+ const value = node.attributes[attribute];
34
+ if (
35
+ typeof value !== "string" ||
36
+ !value ||
37
+ value.length > 256 ||
38
+ /[\x00-\x1f\x7f]/.test(value) ||
39
+ redactLiteral(value) !== value
40
+ )
41
+ continue;
42
+ const selector = `${node.tag}[${attribute}="${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
43
+ if (selector.length <= 512) return selector;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ /** A semantic source fingerprint; never line numbers, absolute paths or sibling ordinals. */
49
+ export function automaticSourceAnchors(file, source) {
50
+ if (!instrumentableSource(file, source.text)) return new Map();
51
+ const ts = dependency("typescript").value;
52
+ const candidates = [],
53
+ groups = new Map();
54
+ function owner(node) {
55
+ for (let parent = node.parent; parent; parent = parent.parent) {
56
+ if (!ts.isFunctionLike(parent)) continue;
57
+ if (parent.name && ts.isIdentifier(parent.name)) return parent.name.text;
58
+ if (
59
+ ts.isVariableDeclaration(parent.parent) &&
60
+ ts.isIdentifier(parent.parent.name)
61
+ )
62
+ return parent.parent.name.text;
63
+ if (
64
+ ts.isExportAssignment(parent.parent) ||
65
+ parent.modifiers?.some(
66
+ (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
67
+ )
68
+ )
69
+ return "default";
70
+ }
71
+ return "module";
72
+ }
73
+ const shapes = new WeakMap();
74
+ function shape(node) {
75
+ if (shapes.has(node)) return shapes.get(node);
76
+ let value;
77
+ if (ts.isJsxText(node))
78
+ value = [node.kind, node.text.replace(/\s+/g, " ").trim()];
79
+ else if (
80
+ ts.isIdentifier(node) ||
81
+ ts.isStringLiteral(node) ||
82
+ ts.isNumericLiteral(node)
83
+ )
84
+ value = [node.kind, node.text];
85
+ else {
86
+ const children = [];
87
+ ts.forEachChild(node, (child) => {
88
+ if (!ts.isJsxText(child) || child.text.trim())
89
+ children.push(shape(child));
90
+ });
91
+ value = [node.kind, ...children];
92
+ }
93
+ const digest = sha256(JSON.stringify(value));
94
+ shapes.set(node, digest);
95
+ return digest;
96
+ }
97
+ function visit(node) {
98
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
99
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
100
+ const tag = opening.tagName.getText(source);
101
+ if (
102
+ /^[a-z][a-z0-9-]*$/.test(tag) &&
103
+ !["html", "head", "script", "style", "link", "meta"].includes(tag) &&
104
+ !opening.attributes.properties.some(
105
+ (attribute) =>
106
+ ts.isJsxAttribute(attribute) &&
107
+ anchorAttributes.includes(attribute.name.getText(source)),
108
+ )
109
+ ) {
110
+ const signature =
111
+ file.replaceAll("\\", "/") + "\0" + owner(node) + "\0" + shape(node);
112
+ const anchor =
113
+ "iam_" + sha256("iam/build-anchor/v1\0" + signature).slice(0, 32);
114
+ candidates.push({ opening, anchor });
115
+ groups.set(anchor, (groups.get(anchor) ?? 0) + 1);
116
+ }
117
+ }
118
+ ts.forEachChild(node, visit);
119
+ }
120
+ visit(source);
121
+ // Identical source sites cannot be disambiguated by their position. Leave them read-only.
122
+ return new Map(
123
+ candidates
124
+ .filter((candidate) => groups.get(candidate.anchor) === 1)
125
+ .map(({ opening, anchor }) => [opening, anchor]),
126
+ );
127
+ }
@@ -4,6 +4,10 @@ import type { BuildLogger } from "./index.js";
4
4
  export type IndianaIamBuildOptions = {
5
5
  /** Set false to build without generating an IAM. */
6
6
  enabled?: boolean;
7
+ /** Reuse existing UI IDs and add build-only anchors for unambiguous native JSX (default: true). */
8
+ automaticAnchors?: boolean;
9
+ /** Browser entry relative to root for apps with multiple source roots. */
10
+ entry?: string;
7
11
  /** App name recorded in the IAM; defaults to the root folder name. */
8
12
  name?: string;
9
13
  /** Fail the build when the IAM cannot be generated (default: warn and continue). */
@@ -15,6 +19,9 @@ export type IndianaIamBuildOptions = {
15
19
  logger?: BuildLogger;
16
20
  /** Key holding only app-manifests:write; defaults to `INDIANA_IAM_KEY`. Without one the IAM is not uploaded. */
17
21
  apiKey?: string;
22
+ /** Client this build belongs to, such as `web` or `web-dashboard`; each client has its own current version.
23
+ * Defaults to `INDIANA_IAM_CLIENT` or `web`. */
24
+ client?: string;
18
25
  /** Indiana origin; defaults to `INDIANA_IAM_BASE_URL` or https://tinker.cash. */
19
26
  baseUrl?: string;
20
27
  /** "production" (default) skips Vercel preview deployments; "always" uploads every build; false never uploads. */