@tinkerlandai/indiana-iam 0.1.0 → 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.
package/README.md CHANGED
@@ -40,6 +40,13 @@ Point the visual SDK at the descriptor:
40
40
 
41
41
  `next dev` and other phases leave your config unchanged. Add `public/.well-known/indiana/` to `.gitignore`.
42
42
 
43
+ No developer harness or business SDK npm dependency is needed. The public script above runs the visual runtime;
44
+ Indiana owns authorization, immutable uploads and experiment publication.
45
+ IAM does not discover or launch merchant harnesses, test scripts, agents, browsers or development servers. It does
46
+ not import merchant modules to execute them. Source enumeration and optional Git commit labels use filesystem reads;
47
+ the package starts no external commands and works without a Git executable. Developers' explicitly configured
48
+ framework callbacks remain part of their normal build.
49
+
43
50
  ## Vite and React Router
44
51
 
45
52
  ```js
@@ -54,9 +61,28 @@ Server builds are skipped.
54
61
 
55
62
  ## Options
56
63
 
57
- Both integrations accept `{ enabled, name, strict, rootFont, omitSvgResources, logger, apiKey, baseUrl, upload }`;
64
+ Both integrations accept `{ enabled, name, automaticAnchors, strict, rootFont, omitSvgResources, logger, apiKey, baseUrl, upload }`;
58
65
  `withIndianaIam` also takes `root`. By default a source the scanner cannot read prints a warning and the build continues without an IAM;
59
66
  `strict: true` fails the build instead. The same source always produces the same release ID and IAM bytes.
67
+ If source changes during compilation, generation fails instead of publishing a descriptor for different code.
68
+
69
+ ## Target identity and release changes
70
+
71
+ Framework builds reuse `id`, `data-testid`, `data-test`, `data-qa`, then `data-indiana-id`. When none is declared,
72
+ the source loader adds a deterministic `data-indiana-id` to unambiguous native JSX in the build output and IAM.
73
+ Source files stay unchanged; formatting, comments and unrelated sibling insertions preserve a leaf's marker.
74
+ Set `automaticAnchors: false` to use only existing attributes.
75
+
76
+ Repeated component instances, identical source sites, unknown props and duplicate DOM selectors cannot prove a
77
+ unique target and remain read-only. React `key` contributes source evidence but is not a DOM selector or a global ID;
78
+ dynamic keys are never evaluated a second time or exposed as customer identifiers. Supported routing and state
79
+ coverage stays bounded and explicit.
80
+
81
+ The visual runtime requests delivery for the page's exact IAM digest. Indiana rebinds saved changes only when the
82
+ route, composition, target selector, element content and render uniqueness still match. Text changes also require
83
+ unchanged subtree content. A failure excludes the whole experiment for that release; it does not renormalize
84
+ variation weights or rewrite authored pins. Old deployments keep their own bindings during a rolling release.
85
+ The Console reports incompatible references for review. Local extraction does not certify arbitrary runtime data.
60
86
 
61
87
  ## Other builds
62
88
 
@@ -67,8 +93,9 @@ indiana-iam prepare --root .
67
93
  indiana-iam anchor --root . --out <plan.json> [--apply]
68
94
  ```
69
95
 
70
- `anchor` adds reviewable `data-indiana-id` attributes to JSX elements so saved changes keep targeting the same elements
71
- across releases.
96
+ Standalone generation uses existing attributes. Custom builds can pair `transformSourceForIam(code, relativeFile)`
97
+ with `generateAppManifest({ root, automaticAnchors: true })`; use both on the same original source. `anchor` is an
98
+ optional command for explicit source annotations. Next and Vite do not need that command or a harness.
72
99
 
73
100
  ## Scope
74
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinkerlandai/indiana-iam",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Build-time Indiana App Manifest (IAM) generator for Next.js and Vite apps: scans source after the production build and writes the IAM and its release descriptor.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -46,6 +46,7 @@
46
46
  "ajv": "6.15.0",
47
47
  "css-tree": "3.2.1",
48
48
  "jsdom": "26.1.0",
49
+ "magic-string": "0.30.21",
49
50
  "tailwind-merge": "3.7.0",
50
51
  "tailwindcss": "4.3.3",
51
52
  "typescript": "5.9.3"
package/src/analyze.mjs CHANGED
@@ -1,6 +1,7 @@
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 } 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";
6
7
  import { frameworkRoutes, externalImport } from "./framework-routes.mjs";
@@ -26,6 +27,28 @@ const { JSDOM } = dependency("jsdom").value;
26
27
  const UNKNOWN = Symbol("unknown");
27
28
  import { sensitiveName, redactLiteral, privateUrl } from "./privacy.mjs";
28
29
  const isUnknown = (value) => value === UNKNOWN;
30
+ // React numeric styles use px except for unitless properties and CSS variables.
31
+ 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(" "));
32
+ function staticStyle(value, unsupported) {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
34
+ unsupported();
35
+ return "";
36
+ }
37
+ return Object.entries(value).flatMap(([key, raw]) => {
38
+ if (raw == null || typeof raw === "boolean" || raw === "") return [];
39
+ if (!/^(?:[a-zA-Z][a-zA-Z0-9]*|--[a-zA-Z0-9_-]+)$/.test(key) ||
40
+ !["string", "number"].includes(typeof raw) ||
41
+ (typeof raw === "number" && !Number.isFinite(raw)) ||
42
+ /[;{}<>\\]|url\s*\(|expression\s*\(|@import/i.test(String(raw))) {
43
+ unsupported();
44
+ return [];
45
+ }
46
+ const name = key.startsWith("--") ? key : key.replace(/[A-Z]/g, letter => "-" + letter.toLowerCase()).replace(/^ms-/, "-ms-");
47
+ const unprefixed = key.replace(/^(?:Webkit|Moz|ms|O)(?=[A-Z])/, "").replace(/^[A-Z]/, letter => letter.toLowerCase());
48
+ const unit = typeof raw === "number" && raw !== 0 && !key.startsWith("--") && !unitlessStyles.has(unprefixed) ? "px" : "";
49
+ return [name + ":" + String(raw).trim() + unit];
50
+ }).join(";");
51
+ }
29
52
  const allowedTags = new Set(
30
53
  "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
54
  " ",
@@ -133,52 +156,10 @@ export async function analyze({
133
156
  maxFiles = 5000,
134
157
  maxBytes = 100_000_000,
135
158
  navigationPaths = [],
159
+ automaticAnchors = false,
136
160
  }) {
137
161
  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");
162
+ const files = await sourceFiles(root, { maxFiles });
182
163
  const modules = new Map(),
183
164
  inventory = [],
184
165
  gaps = [],
@@ -238,6 +219,7 @@ export async function analyze({
238
219
  file,
239
220
  text,
240
221
  source,
222
+ anchors: automaticAnchors ? automaticSourceAnchors(file, source) : new Map(),
241
223
  bindings: new Map(),
242
224
  imports: new Map(),
243
225
  exports: new Map(),
@@ -968,6 +950,15 @@ export async function analyze({
968
950
  eventInputs(value);
969
951
  return { variableKey, value, payload };
970
952
  }
953
+ function unobservedValue(node, owner, instance) {
954
+ const source = sourceLocation(owner.module, node, owner.symbol);
955
+ const elementKey = identity("element", { component: owner.key, unobservedValue: node.getStart(owner.module.source) - owner.fn.getStart(owner.module.source) });
956
+ const nodeId = identity("node", { page: pageKey, element: elementKey, instance });
957
+ elements.set(elementKey, { elementKey, componentKey: owner.key, tag: "span", source, identityStrategy: "provisional_ast_ordinal", editableProperties: [], binding: "preview_only" });
958
+ record.set(nodeId, { elementKey, source, synthetic: true });
959
+ // Preserve an unknown child boundary. An empty projection must never prove that replacing text is safe.
960
+ return '<span data-iam-node="' + nodeId + '" hidden=""></span>';
961
+ }
971
962
  function renderExpr(node, owner, ctx, instance, depth) {
972
963
  node = unwrap(node);
973
964
  if (!node) return "";
@@ -1117,7 +1108,7 @@ export async function analyze({
1117
1108
  .join("");
1118
1109
  }
1119
1110
  gap("DYNAMIC_COLLECTION", m.file, node.expression.getText(m.source));
1120
- return "";
1111
+ return unobservedValue(node, owner, instance);
1121
1112
  }
1122
1113
  if (!ts.isJsxElement(node) && !ts.isJsxSelfClosingElement(node)) {
1123
1114
  if (
@@ -1141,18 +1132,25 @@ export async function analyze({
1141
1132
  typeof value !== "boolean"
1142
1133
  )
1143
1134
  return escape(value);
1144
- if (isUnknown(value))
1135
+ if (isUnknown(value)) {
1145
1136
  gap("DYNAMIC_VALUE", m.file, node.getText(m.source).slice(0, 100));
1137
+ return unobservedValue(node, owner, instance);
1138
+ }
1146
1139
  return "";
1147
1140
  }
1148
1141
  if (++renderedCount > 12000)
1149
1142
  throw new Error("Rendered node budget exceeded");
1150
1143
  const opening = ts.isJsxElement(node) ? node.openingElement : node,
1151
1144
  tag = opening.tagName.getText(m.source),
1152
- props = {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
1145
+ props = m.anchors.get(opening) ? { "data-indiana-id": m.anchors.get(opening) } : {}, uiHandlers = [], uiBindings = [], forwardedHandlers=[];
1153
1146
  for (const attr of opening.attributes.properties) {
1154
1147
  if (ts.isJsxSpreadAttribute(attr)) {
1155
1148
  const v = evalExpression(attr.expression, m, ctx);
1149
+ if (isUnknown(v)) {
1150
+ for (const attribute of anchorAttributes) delete props[attribute];
1151
+ gap("UNKNOWN_ANCHOR_SPREAD", m.file, "Opaque spread may override element identity");
1152
+ continue;
1153
+ }
1156
1154
  if (v && typeof v === "object") {
1157
1155
  Object.assign(props, v);
1158
1156
  const inherited = forwarded.get(v);
@@ -1313,40 +1311,30 @@ export async function analyze({
1313
1311
  }
1314
1312
  }
1315
1313
  if (!allowedTags.has(htmlTag)) return children;
1316
- const anchor = ["data-indiana-id", "data-testid", "id", "name"]
1314
+ const anchor = anchorAttributes
1317
1315
  .map((key) =>
1318
1316
  typeof props[key] === "string" ? key + "=" + props[key] : null,
1319
1317
  )
1320
1318
  .find(Boolean);
1321
1319
  const ordinal = owner.ordinals.get(node) ?? 0,
1322
- explicitAnchor = anchor && !anchor.startsWith('name='),
1323
- elementKey = identity("element", {
1320
+ explicitAnchor = Boolean(anchor);
1321
+ let elementKey = identity("element", {
1324
1322
  component: owner.key,
1325
1323
  tag,
1326
1324
  anchor: explicitAnchor ? anchor : "jsx:" + ordinal,
1327
1325
  });
1326
+ const source = sourceLocation(m, node, owner.symbol);
1327
+ const prior = elements.get(elementKey);
1328
+ if (prior && (prior.source.file !== source.file || prior.source.line !== source.line || prior.source.column !== source.column)) {
1329
+ // Keep both concrete source sites. The DOM uniqueness check makes duplicate anchors read-only.
1330
+ elementKey = identity("element", { component: owner.key, tag, anchor, ordinal });
1331
+ gap("AMBIGUOUS_ELEMENT_ANCHOR", m.file, tag);
1332
+ }
1328
1333
  const nodeId = identity("node", {
1329
1334
  page: pageKey,
1330
1335
  element: elementKey,
1331
1336
  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
- );
1337
+ });
1350
1338
  elements.set(elementKey, {
1351
1339
  elementKey,
1352
1340
  componentKey: owner.key,
@@ -1380,6 +1368,12 @@ export async function analyze({
1380
1368
  }
1381
1369
  const attrs = { "data-iam-node": nodeId };
1382
1370
  for (const [key, value] of Object.entries(props)) {
1371
+ if (key === "style") {
1372
+ const css = staticStyle(value, () => gap("INLINE_STYLE_UNSUPPORTED", m.file,
1373
+ "Inline style at line " + source.line + ": only static CSS values without resource URLs are reconstructed"));
1374
+ if (css) attrs.style = css;
1375
+ continue;
1376
+ }
1383
1377
  if (isUnknown(value) || value == null || typeof value === "object")
1384
1378
  continue;
1385
1379
  if (["href", "src"].includes(key) && privateUrl(value)) {
@@ -1400,7 +1394,7 @@ export async function analyze({
1400
1394
  clipRule: "clip-rule",
1401
1395
  }[key] || key;
1402
1396
  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(
1397
+ !/^(?: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-test|data-qa|data-iam-(?:adapter|toggle))$/.test(
1404
1398
  attr,
1405
1399
  )
1406
1400
  )
@@ -1712,7 +1706,7 @@ export async function analyze({
1712
1706
  schemaVersion: 2,
1713
1707
  producer: {
1714
1708
  name: "indiana-app-manifest-local",
1715
- version: "0.16.0",
1709
+ version: "0.17.0",
1716
1710
  method: "typescript-ast-and-bounded-static-projection",
1717
1711
  },
1718
1712
  app: { name, platforms: ["web"] },
@@ -0,0 +1,122 @@
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 instrumentableSource(file, text) {
13
+ return (
14
+ typeof text === "string" &&
15
+ Buffer.byteLength(text) <= 1_000_000 &&
16
+ /\.[cm]?[jt]sx?$/.test(file) &&
17
+ !/(?:^|\/)(?:node_modules|\.next|\.git|\.indiana|api|server|secrets?)(?:\/|$)/.test(
18
+ file,
19
+ )
20
+ );
21
+ }
22
+ export const anchorSelectorPattern =
23
+ /^[a-z][a-z0-9-]*\[(?:id|data-testid|data-test|data-qa|data-indiana-id)="(?:[^"\\\x00-\x1f\x7f]|\\["\\])*"\]$/;
24
+
25
+ export function anchorSelector(node) {
26
+ if (node.kind !== "element") return null;
27
+ for (const attribute of anchorAttributes) {
28
+ const value = node.attributes[attribute];
29
+ if (
30
+ typeof value !== "string" ||
31
+ !value ||
32
+ value.length > 256 ||
33
+ /[\x00-\x1f\x7f]/.test(value) ||
34
+ redactLiteral(value) !== value
35
+ )
36
+ continue;
37
+ const selector = `${node.tag}[${attribute}="${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`;
38
+ if (selector.length <= 512) return selector;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** A semantic source fingerprint; never line numbers, absolute paths or sibling ordinals. */
44
+ export function automaticSourceAnchors(file, source) {
45
+ if (!instrumentableSource(file, source.text)) return new Map();
46
+ const ts = dependency("typescript").value;
47
+ const candidates = [],
48
+ groups = new Map();
49
+ function owner(node) {
50
+ for (let parent = node.parent; parent; parent = parent.parent) {
51
+ if (!ts.isFunctionLike(parent)) continue;
52
+ if (parent.name && ts.isIdentifier(parent.name)) return parent.name.text;
53
+ if (
54
+ ts.isVariableDeclaration(parent.parent) &&
55
+ ts.isIdentifier(parent.parent.name)
56
+ )
57
+ return parent.parent.name.text;
58
+ if (
59
+ ts.isExportAssignment(parent.parent) ||
60
+ parent.modifiers?.some(
61
+ (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
62
+ )
63
+ )
64
+ return "default";
65
+ }
66
+ return "module";
67
+ }
68
+ const shapes = new WeakMap();
69
+ function shape(node) {
70
+ if (shapes.has(node)) return shapes.get(node);
71
+ let value;
72
+ if (ts.isJsxText(node))
73
+ value = [node.kind, node.text.replace(/\s+/g, " ").trim()];
74
+ else if (
75
+ ts.isIdentifier(node) ||
76
+ ts.isStringLiteral(node) ||
77
+ ts.isNumericLiteral(node)
78
+ )
79
+ value = [node.kind, node.text];
80
+ else {
81
+ const children = [];
82
+ ts.forEachChild(node, (child) => {
83
+ if (!ts.isJsxText(child) || child.text.trim())
84
+ children.push(shape(child));
85
+ });
86
+ value = [node.kind, ...children];
87
+ }
88
+ const digest = sha256(JSON.stringify(value));
89
+ shapes.set(node, digest);
90
+ return digest;
91
+ }
92
+ function visit(node) {
93
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
94
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
95
+ const tag = opening.tagName.getText(source);
96
+ if (
97
+ /^[a-z][a-z0-9-]*$/.test(tag) &&
98
+ !["html", "head", "script", "style", "link", "meta"].includes(tag) &&
99
+ !opening.attributes.properties.some(
100
+ (attribute) =>
101
+ ts.isJsxAttribute(attribute) &&
102
+ anchorAttributes.includes(attribute.name.getText(source)),
103
+ )
104
+ ) {
105
+ const signature =
106
+ file.replaceAll("\\", "/") + "\0" + owner(node) + "\0" + shape(node);
107
+ const anchor =
108
+ "iam_" + sha256("iam/build-anchor/v1\0" + signature).slice(0, 32);
109
+ candidates.push({ opening, anchor });
110
+ groups.set(anchor, (groups.get(anchor) ?? 0) + 1);
111
+ }
112
+ }
113
+ ts.forEachChild(node, visit);
114
+ }
115
+ visit(source);
116
+ // Identical source sites cannot be disambiguated by their position. Leave them read-only.
117
+ return new Map(
118
+ candidates
119
+ .filter((candidate) => groups.get(candidate.anchor) === 1)
120
+ .map(({ opening, anchor }) => [opening, anchor]),
121
+ );
122
+ }
@@ -4,6 +4,8 @@ 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;
7
9
  /** App name recorded in the IAM; defaults to the root folder name. */
8
10
  name?: string;
9
11
  /** Fail the build when the IAM cannot be generated (default: warn and continue). */
package/src/build.mjs CHANGED
@@ -8,7 +8,6 @@ import { buildLabel, uploadAppManifest, uploadSettings } from "./upload.mjs";
8
8
  const MAX_BUILD_CSS = 16;
9
9
  // Shared with build workers that re-evaluate the build configuration.
10
10
  const STATE = "INDIANA_IAM_BUILD_RELEASE";
11
- const analyses = new Map();
12
11
 
13
12
  const prefix = "[indiana-iam] ";
14
13
  const report = (logger, level, message) => logger[level](prefix + message);
@@ -28,28 +27,16 @@ async function guarded({ strict, logger }, action, what) {
28
27
  * Before compilation: scans source once per build and returns its release ID.
29
28
  * The same source and producer always get the same ID, so the page can embed its descriptor URL before the IAM exists.
30
29
  */
31
- export async function prepareBuildRelease({ root, name, strict = false, logger = console }) {
30
+ export async function prepareBuildRelease({ root, name, automaticAnchors = true, strict = false, logger = console }) {
32
31
  return guarded({ strict, logger }, async () => {
33
32
  root = await fs.realpath(root);
34
- const shared = parseState(process.env[STATE]);
35
- if (shared?.root === root) return { releaseId: shared.releaseId };
36
- const analysis = await analyzeSource({ root, name });
33
+ const analysis = await analyzeSource({ root, name, automaticAnchors });
37
34
  const releaseId = deriveReleaseId(analysis);
38
- analyses.set(root + "\0" + releaseId, analysis);
39
- process.env[STATE] = JSON.stringify({ root, releaseId });
35
+ process.env[STATE] = JSON.stringify({ root, releaseId, automaticAnchors });
40
36
  return { releaseId };
41
37
  }, "IAM generation");
42
38
  }
43
39
 
44
- function parseState(value) {
45
- try {
46
- const state = value ? JSON.parse(value) : null;
47
- return typeof state?.root === "string" && typeof state?.releaseId === "string" ? state : null;
48
- } catch {
49
- return null;
50
- }
51
- }
52
-
53
40
  async function compiledCss(root, directory) {
54
41
  let entries;
55
42
  try {
@@ -72,6 +59,7 @@ export async function finishBuildRelease({
72
59
  root,
73
60
  releaseId,
74
61
  name,
62
+ automaticAnchors = true,
75
63
  cssDirectory,
76
64
  mounts,
77
65
  publicDirectory,
@@ -83,7 +71,8 @@ export async function finishBuildRelease({
83
71
  }) {
84
72
  root = await fs.realpath(root);
85
73
  return guarded({ strict, logger }, async () => {
86
- const analysis = analyses.get(root + "\0" + releaseId) ?? (await analyzeSource({ root, name }));
74
+ const analysis = await analyzeSource({ root, name, automaticAnchors });
75
+ if (deriveReleaseId(analysis) !== releaseId) throw new Error("Source changed during the build; rebuild before publishing IAM");
87
76
  const css = cssDirectory ? await compiledCss(root, cssDirectory) : [];
88
77
  const plain = () => generateAppManifest({ root, analysis, releaseId });
89
78
  let result;
@@ -130,6 +119,5 @@ export async function finishBuildRelease({
130
119
 
131
120
  /** Clears the per-build state; build tools call this only from tests. */
132
121
  export function resetBuildRelease() {
133
- analyses.clear();
134
122
  delete process.env[STATE];
135
123
  }
package/src/generate.mjs CHANGED
@@ -11,9 +11,9 @@ import { refreshRenderHashes } from "./render-index.mjs";
11
11
  export const IAM_MAX_BYTES = 8_388_608;
12
12
 
13
13
  /** Scans the source checkout. The result is the input of {@link generateAppManifest}. */
14
- export async function analyzeSource({ root, name, repository = "", commit = "", navigationPaths = [] }) {
14
+ export async function analyzeSource({ root, name, repository = "", commit = "", navigationPaths = [], automaticAnchors = false }) {
15
15
  root = await fs.realpath(root);
16
- return analyze({ root, name: name || path.basename(root), repository, commit, navigationPaths });
16
+ return analyze({ root, name: name || path.basename(root), repository, commit, navigationPaths, automaticAnchors });
17
17
  }
18
18
 
19
19
  /**
@@ -27,6 +27,7 @@ export async function generateAppManifest({
27
27
  repository,
28
28
  commit,
29
29
  navigationPaths,
30
+ automaticAnchors = false,
30
31
  releaseId,
31
32
  buildCss = [],
32
33
  publicAssets = [],
@@ -38,7 +39,7 @@ export async function generateAppManifest({
38
39
  root = await fs.realpath(root);
39
40
  const manifest = analysis
40
41
  ? structuredClone(analysis)
41
- : await analyzeSource({ root, name, repository, commit, navigationPaths });
42
+ : await analyzeSource({ root, name, repository, commit, navigationPaths, automaticAnchors });
42
43
  if (releaseId) {
43
44
  releasePath(releaseId);
44
45
  manifest.release.runtimeBinding = { schemaVersion: 1, releaseId };
@@ -0,0 +1,61 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const commit = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i;
5
+ function read(file, max = 4096) {
6
+ try {
7
+ if (statSync(file).size <= max) return readFileSync(file, "utf8").trim();
8
+ } catch {
9
+ /* Metadata is optional. */
10
+ }
11
+ return "";
12
+ }
13
+
14
+ /** An optional display label only. No executable, hook, config or credential helper is loaded. */
15
+ export function repositoryCommit(root) {
16
+ let directory = path.resolve(root);
17
+ while (true) {
18
+ const entry = path.join(directory, ".git");
19
+ let git;
20
+ try {
21
+ const stat = statSync(entry);
22
+ if (stat.isDirectory()) git = entry;
23
+ else if (stat.isFile()) {
24
+ const match = /^gitdir: (.+)$/.exec(read(entry));
25
+ if (match) git = path.resolve(directory, match[1]);
26
+ }
27
+ } catch {
28
+ /* Look for a containing repository. */
29
+ }
30
+ if (git) {
31
+ const head = read(path.join(git, "HEAD"));
32
+ if (commit.test(head)) return head;
33
+ const match = /^ref: (refs\/[A-Za-z0-9._/-]+)$/.exec(head);
34
+ if (
35
+ !match ||
36
+ match[1]
37
+ .split("/")
38
+ .some((part) => !part || part === "." || part === "..")
39
+ )
40
+ return "";
41
+ const common = read(path.join(git, "commondir"));
42
+ for (const metadata of [
43
+ ...new Set([git, ...(common ? [path.resolve(git, common)] : [])]),
44
+ ]) {
45
+ const loose = read(path.join(metadata, match[1]));
46
+ if (commit.test(loose)) return loose;
47
+ for (const line of read(
48
+ path.join(metadata, "packed-refs"),
49
+ 1_000_000,
50
+ ).split(/\r?\n/)) {
51
+ const [hash, ref] = line.split(" ");
52
+ if (ref === match[1] && commit.test(hash)) return hash;
53
+ }
54
+ }
55
+ return "";
56
+ }
57
+ const parent = path.dirname(directory);
58
+ if (parent === directory) return "";
59
+ directory = parent;
60
+ }
61
+ }