@tinkerlandai/indiana-iam 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.d.ts CHANGED
@@ -20,6 +20,7 @@ export type ValidationCounts = Record<string, unknown>;
20
20
 
21
21
  export type GenerateOptions = {
22
22
  root: string;
23
+ automaticAnchors?: boolean;
23
24
  /** A previous {@link analyzeSource} result; scanned again when omitted. */
24
25
  analysis?: AppManifest;
25
26
  name?: string;
@@ -46,18 +47,23 @@ export type GeneratedAppManifest = {
46
47
  export declare const IAM_MAX_BYTES: 8388608;
47
48
  export declare function analyzeSource(options: {
48
49
  root: string;
50
+ automaticAnchors?: boolean;
49
51
  name?: string;
50
52
  repository?: string;
51
53
  commit?: string;
52
54
  navigationPaths?: string[][];
53
55
  }): Promise<AppManifest>;
54
- export declare function generateAppManifest(options: GenerateOptions): Promise<GeneratedAppManifest>;
56
+ export declare function generateAppManifest(
57
+ options: GenerateOptions,
58
+ ): Promise<GeneratedAppManifest>;
55
59
  export declare function writeAppManifestOutput(options: {
56
60
  root: string;
57
61
  out: string;
58
62
  result: GeneratedAppManifest;
59
63
  }): Promise<{ out: string; file: string; receipt: Record<string, unknown> }>;
60
- export declare function validateAppManifest(manifest: AppManifest): Promise<ValidationCounts>;
64
+ export declare function validateAppManifest(
65
+ manifest: AppManifest,
66
+ ): Promise<ValidationCounts>;
61
67
  export declare function parseAppManifest(text: string): AppManifest;
62
68
  export declare function deriveReleaseId(manifest: AppManifest): string;
63
69
  export declare function releasePath(releaseId: string): string;
@@ -67,10 +73,17 @@ export declare function writePublicRelease(
67
73
  releaseId: string,
68
74
  publicDirectory?: string,
69
75
  ): Promise<{ path: string; bytes: number }>;
70
- export declare function nextStaticMounts(distDir?: string, basePath?: string): StaticMount[];
76
+ export declare function nextStaticMounts(
77
+ distDir?: string,
78
+ basePath?: string,
79
+ ): StaticMount[];
71
80
 
72
81
  export type BuildLogger = Pick<Console, "log" | "warn">;
73
- export type BuildReleaseOptions = { strict?: boolean; logger?: BuildLogger };
82
+ export type BuildReleaseOptions = {
83
+ strict?: boolean;
84
+ logger?: BuildLogger;
85
+ automaticAnchors?: boolean;
86
+ };
74
87
  export declare function prepareBuildRelease(
75
88
  options: { root: string; name?: string } & BuildReleaseOptions,
76
89
  ): Promise<{ releaseId: string } | null>;
@@ -84,7 +97,12 @@ export declare function finishBuildRelease(
84
97
  publicDirectory: string;
85
98
  rootFont?: string;
86
99
  omitSvgResources?: boolean;
87
- upload?: { apiKey?: string; baseUrl?: string; upload?: "production" | "always" | false; fetch?: typeof globalThis.fetch };
100
+ upload?: {
101
+ apiKey?: string;
102
+ baseUrl?: string;
103
+ upload?: "production" | "always" | false;
104
+ fetch?: typeof globalThis.fetch;
105
+ };
88
106
  } & BuildReleaseOptions,
89
107
  ): Promise<{
90
108
  releaseId: string;
@@ -103,14 +121,88 @@ export declare function uploadAppManifest(options: {
103
121
  apiKey: string;
104
122
  baseUrl?: string;
105
123
  fetch?: typeof globalThis.fetch;
106
- }): Promise<{ version: { id: string; state: string; isCurrent: boolean } & Record<string, unknown>; uploaded: boolean }>;
124
+ }): Promise<{
125
+ version: { id: string; state: string; isCurrent: boolean } & Record<
126
+ string,
127
+ unknown
128
+ >;
129
+ uploaded: boolean;
130
+ }>;
107
131
  export declare function uploadSettings(
108
- options?: { apiKey?: string; baseUrl?: string; upload?: "production" | "always" | false | true },
132
+ options?: {
133
+ apiKey?: string;
134
+ baseUrl?: string;
135
+ upload?: "production" | "always" | false | true;
136
+ },
109
137
  env?: Record<string, string | undefined>,
110
138
  ): { enabled: boolean; apiKey: string; baseUrl: string; missingKey: boolean };
111
- export declare function buildLabel(root: string, env?: Record<string, string | undefined>): string;
139
+ export declare function buildLabel(
140
+ root: string,
141
+ env?: Record<string, string | undefined>,
142
+ ): string;
112
143
  export declare function planAnchors(
113
144
  root: string,
114
- options?: { primitives?: { module: string; exports: string[] }[]; publicLabels?: boolean; forwardAnchors?: boolean },
115
- ): Promise<{ root: string; changes: { file: string; anchors: number }[] } & Record<string, unknown>>;
116
- export declare function applyAnchors(plan: Awaited<ReturnType<typeof planAnchors>>): Promise<{ files: number; anchors: number }>;
145
+ options?: {
146
+ primitives?: { module: string; exports: string[] }[];
147
+ publicLabels?: boolean;
148
+ forwardAnchors?: boolean;
149
+ },
150
+ ): Promise<
151
+ { root: string; changes: { file: string; anchors: number }[] } & Record<
152
+ string,
153
+ unknown
154
+ >
155
+ >;
156
+ export declare function applyAnchors(
157
+ plan: Awaited<ReturnType<typeof planAnchors>>,
158
+ ): Promise<{ files: number; anchors: number }>;
159
+ export type IamSourceTransform = {
160
+ code: string;
161
+ map: {
162
+ version: 3;
163
+ file?: string;
164
+ sources: string[];
165
+ sourcesContent: string[];
166
+ names: string[];
167
+ mappings: string;
168
+ toString(): string;
169
+ };
170
+ };
171
+ export declare function transformSourceForIam(
172
+ code: string,
173
+ file: string,
174
+ ): IamSourceTransform | null;
175
+
176
+ export type IamBindingSource = {
177
+ versionId: string;
178
+ manifestDigest: string;
179
+ sourceGraphDigest: string;
180
+ pageKey: string;
181
+ stateKey: string;
182
+ documentKey: string;
183
+ documentHash: string;
184
+ renderKey: string;
185
+ patches: {
186
+ nodeId: string;
187
+ elementKey: string;
188
+ expectedContentHash: string;
189
+ property: "text" | "fontSize" | "color";
190
+ value: string | number;
191
+ }[];
192
+ };
193
+ /** Facts must come from fully validated immutable IAM artifacts in the same App/environment. */
194
+ export declare function rebindIamSource(
195
+ previous: {
196
+ id: string;
197
+ sha256: string;
198
+ sourceGraphDigest: string;
199
+ routeTree: unknown;
200
+ },
201
+ next: {
202
+ id: string;
203
+ sha256: string;
204
+ sourceGraphDigest: string;
205
+ routeTree: unknown;
206
+ },
207
+ source: IamBindingSource,
208
+ ): { ok: true; source: IamBindingSource } | { ok: false; reason: string };
package/src/index.mjs CHANGED
@@ -3,5 +3,7 @@ export { validate as validateAppManifest, parseManifest as parseAppManifest } fr
3
3
  export { deriveReleaseId, releasePath, writePublicRelease } from "./release-metadata.mjs";
4
4
  export { finishBuildRelease, prepareBuildRelease } from "./build.mjs";
5
5
  export { nextStaticMounts } from "./build-styles.mjs";
6
+ export { transformSourceForIam } from "./transform.mjs";
7
+ export { rebindIamSource } from "./rebind.mjs";
6
8
  export { planAnchors, applyAnchors } from "./instrument.mjs";
7
9
  export { uploadAppManifest, uploadSettings, buildLabel, DEFAULT_BASE_URL } from "./upload.mjs";
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { execFileSync } from "node:child_process";
3
+ import { sourceFiles } from "./source-files.mjs";
4
4
  import { dependency } from "./dependencies.mjs";
5
5
  import { sha256 } from "./integrity.mjs";
6
6
  import{redactLiteral}from'./privacy.mjs';
@@ -10,7 +10,7 @@ export async function planAnchors(root,options={}) {
10
10
  const primitives=options.primitives??[];
11
11
  if(!Array.isArray(primitives)||primitives.length>32||primitives.some(p=>typeof p.module!=='string'||p.module.length>256||!Array.isArray(p.exports)||p.exports.length>64||p.exports.some(e=>typeof e!=='string'||!/^default$|^[A-Z][\w]*$/.test(e))))throw Error('Invalid reviewed primitive map');
12
12
  root = await fs.realpath(root);
13
- const files = execFileSync("git", ["-C", root, "ls-files", "-z"], { encoding: "utf8", maxBuffer: 8_000_000 }).split("\0").filter(file => /\.[jt]sx$/.test(file) && !/(^|\/)(?:node_modules|\.next|\.git|\.indiana|dist|build|api|server|secrets?)(\/|$)/.test(file));
13
+ const files = await sourceFiles(root, { maxFiles: 3000, include: file => /\.[jt]sx$/.test(file) });
14
14
  if (files.length > 3000) throw new Error("Instrumentation file budget exceeded");
15
15
  const changes = [];
16
16
  for (const file of files.sort()) {
@@ -8,6 +8,7 @@ import{renderGuardFor}from'./render-index.mjs';
8
8
  import { documentScript } from "./shared-scripts.mjs";
9
9
  import { documentNodes } from './node-hashes.mjs';
10
10
  export { documentNodes as iamDocumentNodes } from './node-hashes.mjs';
11
+ export { rebindIamSource } from './rebind.mjs';
11
12
  export const parseIamJson = parseManifest;
12
13
  export async function isReviewedIamScript(value) {
13
14
  return typeof value === "string" && (await loadTrustedRuntimes()).includes(value);
@@ -0,0 +1,30 @@
1
+ const path = require("node:path");
2
+ const fs = require("node:fs/promises");
3
+
4
+ module.exports = function indianaIamLoader(source, inputMap) {
5
+ this.cacheable?.();
6
+ const done = this.async();
7
+ const { root } = this.getOptions();
8
+ Promise.all([
9
+ import("./transform.mjs"),
10
+ fs.realpath(this.resourcePath),
11
+ fs.realpath(root),
12
+ ])
13
+ .then(([{ transformSourceForIam }, resource, directory]) => {
14
+ const file = path.relative(directory, resource).split(path.sep).join("/");
15
+ if (file.startsWith("../") || path.isAbsolute(file))
16
+ return done(null, source, inputMap);
17
+ // A pre-loader sees original source; refusing an upstream map prevents silently corrupting diagnostics.
18
+ if (inputMap)
19
+ throw new Error(
20
+ "Indiana IAM source loader must run before source transforms",
21
+ );
22
+ const result = transformSourceForIam(source, file);
23
+ done(
24
+ null,
25
+ result?.code ?? source,
26
+ result ? JSON.parse(result.map.toString()) : inputMap,
27
+ );
28
+ })
29
+ .catch(done);
30
+ };
package/src/next.mjs CHANGED
@@ -1,9 +1,11 @@
1
1
  import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
2
3
  import { finishBuildRelease, prepareBuildRelease } from "./build.mjs";
3
4
  import { nextStaticMounts } from "./build-styles.mjs";
4
5
  import { releasePath } from "./release-metadata.mjs";
5
6
 
6
7
  const PRODUCTION_BUILD = "phase-production-build";
8
+ const sourceLoader = fileURLToPath(new URL("./next-loader.cjs", import.meta.url));
7
9
 
8
10
  /**
9
11
  * Wraps a Next.js config so each production build generates the App's IAM.
@@ -15,16 +17,35 @@ const PRODUCTION_BUILD = "phase-production-build";
15
17
  * for production deployments). Development servers and other phases get the config unchanged.
16
18
  */
17
19
  export function withIndianaIam(nextConfig = {}, options = {}) {
18
- const { enabled = true, root, name, strict = false, rootFont, omitSvgResources, logger = console, apiKey, baseUrl, upload, fetch } = options;
20
+ const { enabled = true, root, name, automaticAnchors = true, strict = false, rootFont, omitSvgResources, logger = console, apiKey, baseUrl, upload, fetch } = options;
19
21
  return async (phase, context) => {
20
22
  const config = typeof nextConfig === "function" ? await nextConfig(phase, context) : nextConfig;
21
23
  if (phase !== PRODUCTION_BUILD || !enabled) return config;
22
- const prepared = await prepareBuildRelease({ root: root ?? process.cwd(), name, strict, logger });
24
+ const directory = path.resolve(root ?? process.cwd());
25
+ const prepared = await prepareBuildRelease({ root: directory, name, automaticAnchors, strict, logger });
23
26
  if (!prepared) return config;
24
27
  const { releaseId } = prepared;
25
28
  const previous = config.compiler?.runAfterProductionCompile;
29
+ const pattern = "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
30
+ const existingRules = config.turbopack?.rules ?? {};
31
+ const rule = { loaders: [{ loader: sourceLoader, options: { root: directory } }] };
32
+ const matching = existingRules[pattern];
26
33
  return {
27
34
  ...config,
35
+ ...(automaticAnchors ? {
36
+ turbopack: { ...config.turbopack, rules: {
37
+ [pattern]: matching ? [rule, ...(Array.isArray(matching) ? matching : [matching])] : rule,
38
+ ...Object.fromEntries(Object.entries(existingRules).filter(([key]) => key !== pattern)),
39
+ } },
40
+ webpack(webpackConfig, context) {
41
+ const result = config.webpack ? config.webpack(webpackConfig, context) : webpackConfig;
42
+ if (result?.then) throw new Error("Next.js webpack hooks must return a synchronous config");
43
+ result.module ??= {};
44
+ result.module.rules ??= [];
45
+ result.module.rules.unshift({ test: /\.[cm]?[jt]sx?$/, include: directory, exclude: /node_modules/, enforce: "pre", use: [{ loader: sourceLoader, options: { root: directory } }] });
46
+ return result;
47
+ },
48
+ } : {}),
28
49
  env: { ...config.env, INDIANA_IAM_RELEASE_URL: releasePath(releaseId) },
29
50
  compiler: {
30
51
  ...config.compiler,
@@ -35,6 +56,7 @@ export function withIndianaIam(nextConfig = {}, options = {}) {
35
56
  root: build.projectDir,
36
57
  releaseId,
37
58
  name,
59
+ automaticAnchors,
38
60
  cssDirectory: distDir + "/static",
39
61
  mounts: nextStaticMounts(distDir, config.basePath ?? ""),
40
62
  publicDirectory: "public",
package/src/rebind.mjs ADDED
@@ -0,0 +1,116 @@
1
+ /** Match admitted immutable route indexes. Neither line numbers nor DOM order prove identity. */
2
+ export function rebindIamSource(previous, next, source) {
3
+ const fail = (reason) => ({ ok: false, reason });
4
+ const a = previous.routeTree,
5
+ b = next.routeTree;
6
+ if (
7
+ source.versionId !== previous.id ||
8
+ source.manifestDigest !== previous.sha256 ||
9
+ source.sourceGraphDigest !== previous.sourceGraphDigest ||
10
+ a?.schemaVersion !== 3 ||
11
+ b?.schemaVersion !== 3
12
+ )
13
+ return fail("source_mismatch");
14
+ if (
15
+ !Array.isArray(source.patches) ||
16
+ source.patches.length < 1 ||
17
+ source.patches.length > 200
18
+ )
19
+ return fail("invalid_patches");
20
+ const route = a.routes.find((row) => row.routeKey === source.pageKey);
21
+ const render = a.renders.find(
22
+ (row) =>
23
+ row.routeKey === source.pageKey && row.renderKey === source.renderKey,
24
+ );
25
+ if (
26
+ !route ||
27
+ !render ||
28
+ render.documentKey !== source.documentKey ||
29
+ render.documentHash !== source.documentHash ||
30
+ render.stateKey !== source.stateKey
31
+ )
32
+ return fail("source_mismatch");
33
+ const routes = b.routes.filter((row) => row.path === route.path);
34
+ if (routes.length !== 1) return fail("route_changed");
35
+ const seen = new Set(),
36
+ targets = [];
37
+ for (const patch of source.patches) {
38
+ const key = patch.nodeId + "\0" + patch.property;
39
+ const old = a.targets.filter(
40
+ (row) =>
41
+ row.routeKey === source.pageKey &&
42
+ row.renderKey === render.renderKey &&
43
+ row.nodeId === patch.nodeId &&
44
+ row.elementKey === patch.elementKey &&
45
+ row.contentHash === patch.expectedContentHash,
46
+ );
47
+ if (seen.has(key) || old.length !== 1) return fail("source_mismatch");
48
+ seen.add(key);
49
+ targets.push(old[0]);
50
+ }
51
+ const candidates = [];
52
+ for (const candidate of b.renders) {
53
+ if (
54
+ candidate.routeKey !== routes[0].routeKey ||
55
+ candidate.entryMode !== render.entryMode ||
56
+ candidate.compositionKey !== render.compositionKey
57
+ )
58
+ continue;
59
+ if (
60
+ !candidate.match.present.length ||
61
+ b.renders.filter(
62
+ (other) =>
63
+ other.routeKey === candidate.routeKey &&
64
+ JSON.stringify(other.match) === JSON.stringify(candidate.match),
65
+ ).length !== 1
66
+ )
67
+ continue;
68
+ const patches = [];
69
+ for (const [index, patch] of source.patches.entries()) {
70
+ const old = targets[index];
71
+ const matches = b.targets.filter(
72
+ (row) =>
73
+ row.routeKey === candidate.routeKey &&
74
+ row.renderKey === candidate.renderKey &&
75
+ row.selector === old.selector &&
76
+ row.tag === old.tag,
77
+ );
78
+ if (matches.length !== 1) break;
79
+ const target = matches[0];
80
+ if (
81
+ target.contentHash !== old.contentHash ||
82
+ (patch.property === "text" &&
83
+ (!old.leaf ||
84
+ !target.leaf ||
85
+ !old.subtreeHash ||
86
+ target.subtreeHash !== old.subtreeHash))
87
+ )
88
+ break;
89
+ patches.push({
90
+ ...patch,
91
+ nodeId: target.nodeId,
92
+ elementKey: target.elementKey,
93
+ expectedContentHash: target.contentHash,
94
+ });
95
+ }
96
+ if (patches.length === source.patches.length)
97
+ candidates.push({ candidate, patches });
98
+ }
99
+ if (candidates.length !== 1)
100
+ return fail(candidates.length ? "render_ambiguous" : "target_changed");
101
+ const { candidate, patches } = candidates[0];
102
+ return {
103
+ ok: true,
104
+ source: {
105
+ versionId: next.id,
106
+ manifestDigest: next.sha256,
107
+ sourceGraphDigest: next.sourceGraphDigest,
108
+ pageKey: candidate.routeKey,
109
+ stateKey: candidate.stateKey,
110
+ documentKey: candidate.documentKey,
111
+ documentHash: candidate.documentHash,
112
+ renderKey: candidate.renderKey,
113
+ patches,
114
+ },
115
+ };
116
+ }
@@ -7,6 +7,6 @@ async function directory(root,parts){let folder=root;for(const part of parts){fo
7
7
  /** Before the native build: embed this immutable URL in the installed SDK. */
8
8
  export async function prepareRelease(root){root=await fs.realpath(root);const folder=await directory(root,['.indiana']),target=path.join(folder,'iam-release-input.json'),releaseId=randomUUID(),value={releaseId,manifestUrl:releasePath(releaseId)};try{if((await fs.lstat(target)).isSymbolicLink())throw Error('Release input cannot be a symlink');}catch(error){if(error.code!=='ENOENT')throw error;}const temporary=target+'.'+randomUUID();await fs.writeFile(temporary,JSON.stringify(value)+'\n',{flag:'wx'});await fs.rename(temporary,target);return value;}
9
9
  /** Build plugins: the same source and producer always get the same release ID, so an unchanged rebuild reproduces the same IAM bytes. */
10
- export function deriveReleaseId(manifest){const digest=manifest?.release?.sourceGraphDigest,version=manifest?.producer?.version;if(typeof digest!=='string'||!digest||typeof version!=='string'||!version)throw Error('Release identity needs an analyzed source graph');const h=createHash('sha256').update('indiana-iam-release/v1\0'+version+'\0'+digest).digest('hex');return h.slice(0,8)+'-'+h.slice(8,12)+'-4'+h.slice(13,16)+'-'+(8|(parseInt(h[16],16)&3)).toString(16)+h.slice(17,20)+'-'+h.slice(20,32);}
10
+ export function deriveReleaseId(manifest){const digest=manifest?.release?.sourceGraphDigest,version=manifest?.producer?.version;if(typeof digest!=='string'||!digest||typeof version!=='string'||!version)throw Error('Release identity needs an analyzed source graph');const h=createHash('sha256').update('indiana-iam-release/v1\0'+version+'\0'+digest+'\0'+JSON.stringify({app:manifest.app,routing:manifest.routing,routeTreeDigest:manifest.integrity?.routeTreeDigest})).digest('hex');return h.slice(0,8)+'-'+h.slice(8,12)+'-4'+h.slice(13,16)+'-'+(8|(parseInt(h[16],16)&3)).toString(16)+h.slice(17,20)+'-'+h.slice(20,32);}
11
11
  /** Explicit post-build write into a served folder under root. Reusing an ID for different bytes fails closed. */
12
12
  export async function writePublicRelease(root,manifestDigest,releaseId,publicDirectory='public'){root=await fs.realpath(root);const url=releasePath(releaseId);if(!/^[a-f0-9]{64}$/.test(manifestDigest))throw Error('Invalid release digest');const parts=publicDirectory.split('/');if(parts.some(part=>!/^[A-Za-z0-9_.-]+$/.test(part)||part==='.'||part==='..'))throw Error('Invalid release directory');const folder=await directory(root,[...parts,'.well-known','indiana','releases']),target=path.join(folder,releaseId+'.json'),bytes=JSON.stringify({format:'indiana-app-manifest-release',schemaVersion:2,releaseId,manifestDigest})+'\n';try{await fs.writeFile(target,bytes,{flag:'wx'});}catch(error){if(error.code!=='EEXIST')throw error;if((await fs.lstat(target)).isSymbolicLink())throw Error('Release metadata cannot be a symlink');if(await fs.readFile(target,'utf8')!==bytes)throw Error('Immutable release ID is occupied');}return{path:url,bytes:Buffer.byteLength(bytes)};}
@@ -1,3 +1,4 @@
1
+ import { anchorSelector, anchorSelectorPattern } from "./anchors.mjs";
1
2
  import { canonical, hashObject, identity } from "./integrity.mjs";
2
3
 
3
4
  export function flattenComposition(root) {
@@ -70,10 +71,9 @@ export function attachRenderIndex(graph, manifest, definitions, navigation=[]) {
70
71
  if (field === "transitions") row.fromStateKey = row.toStateKey = renders.find(render => render.renderKey === row.renderKey).stateKey;
71
72
  }
72
73
  const sets = new Map(renders.map(render => [render.renderKey, stableSelectors(graph, manifest.documents.find(doc => doc.documentKey === render.documentKey), render.renderKey)]));
73
- const selectorPool = [...new Set([...sets.values()].flatMap(set => [...set]))].sort();
74
- if (selectorPool.length > 512) throw new Error("Render discriminator budget exceeded");
75
74
  const rawSets = new Map(renders.map(render => [render.renderKey, rawSelectors(manifest.documents.find(doc => doc.documentKey === render.documentKey))]));
76
75
  const negativeProofs=routerSelectors({...graph,compositions:[...compositions.values()]},manifest);
76
+ const selectorPool = discriminatorPool(graph.routes, renders, sets, rawSets, negativeProofs);
77
77
  const guards = graph.routes.map(route => ({ routeKey: route.routeKey, selectorKeys: guardKeys(renders.filter(render => render.routeKey === route.routeKey), selectorPool, sets, rawSets,negativeProofs) }));
78
78
  for (const render of renders) guards.find(guard => guard.routeKey === render.routeKey).selectorKeys.forEach((poolKey, key) => render.match[sets.get(render.renderKey).has(selectorPool[poolKey]) ? "present" : "absent"].push(key));
79
79
  const renderNavigation=navigation.map(edge=>{
@@ -130,10 +130,10 @@ export function validateRenderIndex(graph, manifest) {
130
130
  }
131
131
  for (const variable of graph.variables) if (renders.get(variable.renderKey)?.routeKey !== variable.routeKey) throw new Error("Cross-render variable");
132
132
  const sets = new Map(graph.renders.map(render => [render.renderKey, stableSelectors(graph, documents.get(render.documentKey), render.renderKey)]));
133
- const expectedPool = [...new Set([...sets.values()].flatMap(set => [...set]))].sort();
134
- if (canonical(expectedPool) !== canonical(graph.selectorPool)) throw new Error("Render discriminator mismatch");
135
133
  const rawSets = new Map(graph.renders.map(render => [render.renderKey, rawSelectors(documents.get(render.documentKey))]));
136
134
  const negativeProofs=routerSelectors(graph,manifest);
135
+ const expectedPool = discriminatorPool(graph.routes, graph.renders, sets, rawSets, negativeProofs);
136
+ if (canonical(expectedPool) !== canonical(graph.selectorPool)) throw new Error("Render discriminator mismatch");
137
137
  for (const route of graph.routes) {
138
138
  const candidates = graph.renders.filter(row => row.routeKey === route.routeKey);
139
139
  const expectedKeys = guardKeys(candidates, expectedPool, sets, rawSets,negativeProofs);
@@ -152,13 +152,32 @@ export function validateRenderIndex(graph, manifest) {
152
152
 
153
153
  function rawSelectors(document) {
154
154
  return new Set(document.nodes.flatMap(node => {
155
- const anchor = node.kind === "element" && node.attributes["data-indiana-id"];
156
- return anchor ? [node.tag + '[data-indiana-id="' + anchor.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + '"]'] : [];
155
+ const selector = anchorSelector(node);
156
+ return selector && anchorSelectorPattern.test(selector) ? [selector] : [];
157
157
  }));
158
158
  }
159
159
  function guardKeys(candidates, pool, stable, raw,negativeProofs) {
160
160
  return pool.flatMap((selector, key) => (!negativeProofs.has(selector)&&!candidates.some(render=>stable.get(render.renderKey).has(selector)))||candidates.some(render => raw.get(render.renderKey).has(selector) && !stable.get(render.renderKey).has(selector)) ? [] : [key]);
161
161
  }
162
+ /** Editable targets do not all need to be render discriminators. Retain positive proof and every distinguishable pair. */
163
+ function discriminatorPool(routes, renders, stable, raw, negative) {
164
+ const all = [...new Set([...stable.values()].flatMap(set => [...set]))].sort();
165
+ if (all.length <= 512) return all;
166
+ const selected = new Set();
167
+ const add = selector => { if (selector) selected.add(selector); if (selected.size > 512) throw new Error("Render discriminator budget exceeded"); };
168
+ for (const route of routes) {
169
+ const candidates = renders.filter(render => render.routeKey === route.routeKey);
170
+ const eligible = guardKeys(candidates, all, stable, raw, negative).map(key => all[key]);
171
+ const allowed = new Set(eligible);
172
+ for (const render of candidates) add(eligible.find(selector => stable.get(render.renderKey).has(selector)));
173
+ for (let i = 0; i < candidates.length; i++) for (let j = i + 1; j < candidates.length; j++) {
174
+ const a = stable.get(candidates[i].renderKey), b = stable.get(candidates[j].renderKey);
175
+ if ([...selected].some(selector => allowed.has(selector) && a.has(selector) !== b.has(selector))) continue;
176
+ add(eligible.find(selector => a.has(selector) !== b.has(selector)));
177
+ }
178
+ }
179
+ return [...selected].sort();
180
+ }
162
181
  /** Shared UI may appear in unresolved lists elsewhere: it can prove presence locally, never absence globally. */
163
182
  function routerSelectors(graph,manifest){
164
183
  const files=new Set(graph.compositions.flatMap(composition=>composition.entries.flatMap(entry=>[entry.file,...entry.layers.map(layer=>layer.file)])));
@@ -182,7 +201,7 @@ function stableSelectors(graph, document, renderKey) {
182
201
  }
183
202
  return true;
184
203
  }
185
- return new Set(graph.targets.filter(target => target.renderKey === renderKey && /^[a-z][a-z0-9-]*\[data-indiana-id="/.test(target.selector) && stable(nodes.get(target.nodeId))).map(target => target.selector));
204
+ return new Set(graph.targets.filter(target => target.renderKey === renderKey && anchorSelectorPattern.test(target.selector) && stable(nodes.get(target.nodeId))).map(target => target.selector));
186
205
  }
187
206
 
188
207
  export function refreshRenderHashes(manifest) {
@@ -1,3 +1,5 @@
1
+ import { anchorSelector } from "./anchors.mjs";
2
+ export { anchorSelector } from "./anchors.mjs";
1
3
  import { dependency } from "./dependencies.mjs";
2
4
  import { canonical } from "./integrity.mjs";
3
5
  import { documentNodes } from './node-hashes.mjs';
@@ -32,6 +34,7 @@ const routeTreeShape = obj({
32
34
  targets: arr(obj({ routeKey: str, nodeId: str, elementKey: str, contentHash: { type: "string", pattern: "^[a-f0-9]{64}$" }, selector: str, tag: str, leaf: { type: "boolean" }, source })),
33
35
  });
34
36
  routeTreeShape.properties.targets.items.required=routeTreeShape.properties.targets.items.required.filter(key=>key!=='source');
37
+ routeTreeShape.properties.targets.items.properties.subtreeHash = { type: "string", pattern: "^[a-f0-9]{64}$" };
35
38
  export const routeTreeSchema = renderTreeSchema(routeTreeShape);
36
39
  const Ajv = dependency("ajv").value;
37
40
  const check = new Ajv({ allErrors: true }).compile({ ...routeTreeSchema, definitions: { uiExpression } });
@@ -110,7 +113,7 @@ export function validateRouteTree(graph, manifest, verifiedSelectors, concreteDo
110
113
  try {
111
114
  for (const target of graph.targets) {
112
115
  const node = mapped(target);
113
- if (targetIds.has(target.nodeId) || node.synthetic || target.contentHash !== node.contentHash || target.tag !== node.tag || target.selector !== anchorSelector(node) || target.leaf !== !node.children.some(id => nodes.get(id).kind === "element")) throw new Error("Invalid SDK target binding");
116
+ if (targetIds.has(target.nodeId) || node.synthetic || target.contentHash !== node.contentHash || target.subtreeHash !== undefined && target.subtreeHash !== node.subtreeHash || target.tag !== node.tag || target.selector !== anchorSelector(node) || target.leaf !== !node.children.some(id => nodes.get(id).kind === "element")) throw new Error("Invalid SDK target binding");
114
117
  targetIds.add(target.nodeId);
115
118
  let count = verifiedSelectors?.get(node.documentKey)?.get(target.selector);
116
119
  if (count === undefined) {
@@ -137,7 +140,7 @@ export function assembleRouteTree(manifest, extracted, renderDefinitions, render
137
140
  try { for (const node of doc.nodes) {
138
141
  const selector = anchorSelector(node);
139
142
  if (!selector || node.synthetic || dom.window.document.querySelectorAll(selector).length !== 1) continue;
140
- targets.push({ routeKey: doc.pageKey, nodeId: node.nodeId, elementKey: node.elementKey, contentHash: node.contentHash, selector, tag: node.tag, leaf: !node.children.some(id => doc.nodes.find(n => n.nodeId === id).kind === "element"), source: node.source });
143
+ targets.push({ routeKey: doc.pageKey, nodeId: node.nodeId, elementKey: node.elementKey, contentHash: node.contentHash, subtreeHash: node.subtreeHash, selector, tag: node.tag, leaf: !node.children.some(id => doc.nodes.find(n => n.nodeId === id).kind === "element"), source: node.source });
141
144
  } } finally { dom.window.close(); }
142
145
  }
143
146
  const graph = { format: "indiana-route-state-index", schemaVersion: 3, profile: "bounded-web-preview", roots: routes.filter(r => r.parentKey === null).map(r => r.routeKey), routes,
@@ -149,16 +152,6 @@ export function assembleRouteTree(manifest, extracted, renderDefinitions, render
149
152
  return attachRenderIndex(graph, manifest, renderDefinitions, renderNavigation);
150
153
  }
151
154
 
152
- export function anchorSelector(node) {
153
- if (node.kind !== "element") return null;
154
- for (const key of ["data-indiana-id", "id", "data-testid", "name"]) {
155
- const value = node.attributes[key];
156
- if (!value || value.length > 256 || Array.from(value).some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) continue;
157
- return node.tag + '[' + key + '="' + value.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + '"]';
158
- }
159
- return null;
160
- }
161
-
162
155
  export function readVariables(expression, result = new Set()) {
163
156
  if (expression.op === "var") result.add(expression.key);
164
157
  for (const child of [expression.arg, expression.left, expression.right, ...(expression.args || []),...(expression.parts||[]).map(part=>part.when)]) if (child) readVariables(child, result);
@@ -0,0 +1,53 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export function sourcePath(file) {
5
+ return (
6
+ /\.(?:[cm]?[jt]sx?|css|mdx|json)$/.test(file) &&
7
+ !/(?:^|\/)(?:api|server|secrets?)(?:\/|$)/.test(file) &&
8
+ !/(?:^|\/)(?:\.?env|credentials|secrets)(?:\.|\/)/i.test(file) &&
9
+ !/(?:lock|package-lock)\.json$/.test(file)
10
+ );
11
+ }
12
+
13
+ /** Source enumeration needs no Git executable, lifecycle script or merchant tooling. */
14
+ export async function sourceFiles(
15
+ root,
16
+ { maxFiles = 5000, include = sourcePath } = {},
17
+ ) {
18
+ const files = [],
19
+ pending = [root];
20
+ let directories = 0;
21
+ while (pending.length) {
22
+ if (++directories > 20_000)
23
+ throw new Error("Source directory budget exceeded");
24
+ const directory = pending.pop();
25
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
26
+ if (
27
+ entry.name.startsWith(".") ||
28
+ [
29
+ "node_modules",
30
+ "dist",
31
+ "build",
32
+ "coverage",
33
+ "api",
34
+ "server",
35
+ "secret",
36
+ "secrets",
37
+ ].includes(entry.name)
38
+ )
39
+ continue;
40
+ const absolute = path.join(directory, entry.name);
41
+ if (entry.isDirectory()) pending.push(absolute);
42
+ else if (entry.isFile()) {
43
+ const file = path.relative(root, absolute).split(path.sep).join("/");
44
+ if (!sourcePath(file) || !include(file)) continue;
45
+ files.push(file);
46
+ if (files.length > maxFiles)
47
+ throw new Error("Source file budget exceeded");
48
+ }
49
+ // Symlinks never expand the authorized source root.
50
+ }
51
+ }
52
+ return files.sort();
53
+ }
@@ -0,0 +1,32 @@
1
+ import MagicString from "magic-string";
2
+ import { dependency } from "./dependencies.mjs";
3
+ import { automaticSourceAnchors, instrumentableSource } from "./anchors.mjs";
4
+
5
+ /** Adds build-only anchors without editing merchant source or evaluating any expression. */
6
+ export function transformSourceForIam(code, file) {
7
+ file = file.replaceAll("\\", "/");
8
+ if (!instrumentableSource(file, code)) return null;
9
+ const ts = dependency("typescript").value;
10
+ const source = ts.createSourceFile(
11
+ file,
12
+ code,
13
+ ts.ScriptTarget.Latest,
14
+ true,
15
+ /\.jsx?$/.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX,
16
+ );
17
+ if (source.parseDiagnostics.length)
18
+ throw new Error("IAM cannot instrument invalid source: " + file);
19
+ const anchors = automaticSourceAnchors(file, source);
20
+ if (!anchors.size) return null;
21
+ const output = new MagicString(code);
22
+ for (const [opening, anchor] of anchors)
23
+ output.appendLeft(opening.tagName.end, ` data-indiana-id="${anchor}"`);
24
+ return {
25
+ code: output.toString(),
26
+ map: output.generateMap({
27
+ source: file,
28
+ includeContent: true,
29
+ hires: true,
30
+ }),
31
+ };
32
+ }
package/src/upload.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { repositoryCommit } from "./git-metadata.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
4
  export const DEFAULT_BASE_URL = "https://tinker.cash";
@@ -18,19 +18,11 @@ export function uploadSettings({ apiKey, baseUrl, upload = "production" } = {},
18
18
 
19
19
  /** A short version label: the commit being built when the CI or git exposes it. */
20
20
  export function buildLabel(root, env = process.env) {
21
- const commit = env.VERCEL_GIT_COMMIT_SHA || env.GITHUB_SHA || env.CI_COMMIT_SHA || gitHead(root);
21
+ const commit = env.VERCEL_GIT_COMMIT_SHA || env.GITHUB_SHA || env.CI_COMMIT_SHA || repositoryCommit(root);
22
22
  if (commit && /^[0-9a-f]{7,64}$/i.test(commit)) return commit.slice(0, 12).toLowerCase();
23
23
  return "build " + new Date().toISOString().slice(0, 16).replace("T", " ");
24
24
  }
25
25
 
26
- function gitHead(root) {
27
- try {
28
- return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
29
- } catch {
30
- return "";
31
- }
32
- }
33
-
34
26
  async function failure(response, step) {
35
27
  let message = response.statusText;
36
28
  try {