@nextcommerce/campaigns-os 1.33.0 → 1.34.1

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +3 -0
  3. package/compatibility.json +1 -1
  4. package/contracts/fixtures/runtime-recipe/accept/current.json +3 -5
  5. package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -5
  6. package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -5
  7. package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -5
  8. package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -5
  9. package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -5
  10. package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -5
  11. package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +3 -5
  12. package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -5
  13. package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -5
  14. package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -5
  15. package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +2 -4
  16. package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -5
  17. package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -5
  18. package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -5
  19. package/contracts/release-ledger.json +255 -0
  20. package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -5
  21. package/contracts/supported-surface.json +5 -3
  22. package/docs/orientation-contract-reference.md +2 -1
  23. package/docs/qa-and-test-orders.md +26 -0
  24. package/docs/runtime-readiness.md +3 -3
  25. package/docs/sdk-storage-compatibility.md +19 -0
  26. package/docs/supported-surface.md +2 -1
  27. package/docs/versioning.md +1 -1
  28. package/package.json +11 -7
  29. package/src/cli.mjs +22 -2
  30. package/src/sdk-storage-compatibility.mjs +359 -0
@@ -0,0 +1,359 @@
1
+ // Read-only source evidence. SDK migration names come exclusively from the supplied manifest.
2
+ import { parse as parseJs } from 'acorn';
3
+ import { parse as parseHtml } from 'parse5';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { readFileSync, lstatSync, realpathSync, statSync } from 'node:fs';
6
+ import { resolve, relative, dirname, posix, sep } from 'node:path';
7
+ import { createHash } from 'node:crypto';
8
+ import { isUtf8 } from 'node:buffer';
9
+ const digest = value => createHash('sha256').update(value).digest('hex');
10
+ const version = value => {
11
+ if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value))
12
+ throw new Error('SDK versions must be exact x.y.z versions.');
13
+ return value.split('.').map(Number);
14
+ };
15
+ const compare = (a, b) => {
16
+ a = version(a);
17
+ b = version(b);
18
+ for (let index = 0; index < 3; index += 1) {
19
+ if (a[index] !== b[index]) return Math.sign(a[index] - b[index]);
20
+ }
21
+ return 0;
22
+ };
23
+ const normalizePattern = key => key.replace(/\{[^}]*\}/g, '{}');
24
+ const bareKey = entry => entry.migration?.legacyKey ?? entry.key.replace('{__scope}', '');
25
+ function matchesTemplate(key, template) {
26
+ const pieces = template.split(/\{[^}]*\}/g);
27
+ if (pieces.length === 1)
28
+ return key === template;
29
+ if (!key.startsWith(pieces[0]) || !key.endsWith(pieces.at(-1)))
30
+ return false;
31
+ let offset = pieces[0].length;
32
+ for (const piece of pieces.slice(1, -1)) {
33
+ const next = key.indexOf(piece, offset);
34
+ if (next < 0)
35
+ return false;
36
+ offset = next + piece.length;
37
+ }
38
+ return true;
39
+ }
40
+ function children(node) { return Object.values(node).flatMap(value => Array.isArray(value) ? value.filter(x => x?.type) : value?.type ? [value] : []); }
41
+ function names(pattern) {
42
+ if (!pattern) return [];
43
+ if (pattern.type === 'Identifier') return [pattern.name];
44
+ return children(pattern).flatMap(names);
45
+ }
46
+ function normalizeScope(value) {
47
+ if (typeof value !== 'string' || !value.length || value.includes('\\') || value.startsWith('/') || value.split('/').includes('..') || /[\[*?]/.test(value))
48
+ throw new Error('Scope and exclusions must be literal repository-relative files or directories.');
49
+ return value.replace(/^\.\//, '').replace(/\/$/, '') || '.';
50
+ }
51
+ const inside = (path, scope) => scope === '.' || path === scope || path.startsWith(scope + '/');
52
+ export function readStorageManifest(path) {
53
+ const bytes = readFileSync(path);
54
+ const value = JSON.parse(bytes);
55
+ if (value.schemaVersion !== 1 || !Array.isArray(value.keys) || !value.provenance || typeof value.provenance.extractor !== 'string' || typeof value.provenance.registry !== 'string' || !/^[a-f0-9]{64}$/.test(value.provenance.inputSha256 ?? ''))
56
+ throw new Error('Unrecognized or incomplete SDK storage manifest.');
57
+ version(value.sdkVersion);
58
+ version(value.supportedSdkVersions?.min);
59
+ version(value.supportedSdkVersions?.max);
60
+ if (compare(value.sdkVersion, value.supportedSdkVersions.max) !== 0)
61
+ throw new Error('Manifest source SDK version must equal supported maximum.');
62
+ const unique = new Set();
63
+ if (compare(value.supportedSdkVersions.min, value.supportedSdkVersions.max) > 0)
64
+ throw new Error('Invalid manifest version range.');
65
+ for (const entry of value.keys) {
66
+ if (typeof entry.key !== 'string' || !entry.key || typeof entry.pattern !== 'string' || entry.pattern !== normalizePattern(entry.key) || entry.scoped !== entry.key.includes('{__scope}') || !Array.isArray(entry.areas) || entry.areas.some(x => !['localStorage', 'sessionStorage'].includes(x)) || typeof entry.scoped !== 'boolean')
67
+ throw new Error('Invalid storage manifest key.');
68
+ if (unique.has(entry.key))
69
+ throw new Error('Duplicate manifest storage key.');
70
+ unique.add(entry.key);
71
+ if (entry.migration !== null && (!entry.migration || typeof entry.migration !== 'object'))
72
+ throw new Error('Migration must be explicit null or evidence object.');
73
+ if (entry.replacement !== null && (!entry.replacement || entry.replacement.kind !== 'public-store' || typeof entry.replacement.export !== 'string' || typeof entry.replacement.guide !== 'string'))
74
+ throw new Error('Invalid public replacement.');
75
+ if (entry.migration) {
76
+ version(entry.migration.since);
77
+ if (!entry.areas.length || !entry.scoped || entry.migration.legacyKey !== entry.key.replace('{__scope}', '') || compare(entry.migration.since, value.sdkVersion) > 0)
78
+ throw new Error('Migration and canonical storage key disagree.');
79
+ if (typeof entry.migration.legacyKey !== 'string' || !/^[a-f0-9]{40}$/.test(entry.migration.releaseEvidence?.commit ?? '') || typeof entry.migration.releaseEvidence?.tag !== 'string')
80
+ throw new Error('Migration lacks verified release evidence.');
81
+ }
82
+ }
83
+ let source = { verification: 'unverified-local-file', repository: null, commit: null, repositoryPath: null };
84
+ try {
85
+ const root = execFileSync('git', ['-C', dirname(resolve(path)), 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
86
+ const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
87
+ const repositoryPath = relative(root, resolve(path)).split(sep).join('/');
88
+ const blob = execFileSync('git', ['-C', root, 'show', `${commit}:${repositoryPath}`], { stdio: ['ignore', 'pipe', 'ignore'] });
89
+ if (digest(blob) === digest(bytes))
90
+ source = { verification: 'verified-git-blob', repository: root, commit, repositoryPath };
91
+ }
92
+ catch { /* A proposed generated manifest may not have been committed yet. */ }
93
+ return { value, evidence: { path: resolve(path), sha256: digest(bytes), sdkVersion: value.sdkVersion, provenance: value.provenance, supportedSdkVersions: value.supportedSdkVersions, source } };
94
+ }
95
+ export function analyzeStorageJavaScript(source, { path = '<source>', lineOffset = 0, columnOffset = 0, manifest, targetSdkVersion } = {}) {
96
+ const findings = [];
97
+ const emit = (node, data) => findings.push({ path, line: (node.loc?.start.line ?? 1) + lineOffset, column: (node.loc?.start.column ?? 0) + 1 + ((node.loc?.start.line ?? 1) === 1 ? columnOffset : 0), ...data });
98
+ let ast;
99
+ try {
100
+ ast = parseJs(source, { ecmaVersion: 'latest', sourceType: 'module', locations: true, allowHashBang: true });
101
+ }
102
+ catch (error) {
103
+ emit({ loc: { start: error.loc } }, { operation: 'unknown', area: null, key: null, status: 'unknown', reason: 'parse-failure', detail: error.message, replacement: null });
104
+ return findings;
105
+ }
106
+ const root = { bindings: new Map(), parent: null };
107
+ const scopes = new WeakMap();
108
+ function collect(node, scope) {
109
+ if (['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression', 'BlockStatement', 'CatchClause', 'ForStatement', 'ForInStatement', 'ForOfStatement', 'SwitchStatement', 'StaticBlock', 'ClassExpression', 'ClassDeclaration'].includes(node.type)) {
110
+ if (['FunctionDeclaration', 'ClassDeclaration'].includes(node.type) && node.id)
111
+ scope.bindings.set(node.id.name, { unknown: true });
112
+ scope = { bindings: new Map(), parent: scope };
113
+ for (const p of node.params ?? [])
114
+ for (const name of names(p))
115
+ scope.bindings.set(name, { unknown: true });
116
+ for (const name of names(node.param))
117
+ scope.bindings.set(name, { unknown: true });
118
+ if (['FunctionExpression', 'ClassExpression', 'ClassDeclaration'].includes(node.type) && node.id)
119
+ scope.bindings.set(node.id.name, { unknown: true });
120
+ }
121
+ scopes.set(node, scope);
122
+ if (node.type === 'VariableDeclaration')
123
+ for (const d of node.declarations)
124
+ for (const name of names(d.id)) {
125
+ let target = scope;
126
+ if (node.kind === 'var')
127
+ while (target.parent && !target.functionScope)
128
+ target = target.parent;
129
+ const property = d.id.type === 'ObjectPattern' ? d.id.properties.find(p => p.type === 'Property' && p.value.type === 'Identifier' && p.value.name === name) : null;
130
+ target.bindings.set(name, {
131
+ init: node.kind === 'const' && d.id.type === 'Identifier' ? d.init : null,
132
+ destructure: property ? { object: d.init, property, mutable: node.kind !== 'const' } : null,
133
+ scope, start: d.start,
134
+ });
135
+ }
136
+ if (node.type.startsWith('Function') || node.type === 'ArrowFunctionExpression' || node.type === 'StaticBlock')
137
+ scope.functionScope = true;
138
+ if (node.type === 'ImportDeclaration')
139
+ for (const s of node.specifiers)
140
+ scope.bindings.set(s.local.name, { unknown: true });
141
+ for (const child of children(node))
142
+ collect(child, scope);
143
+ }
144
+ collect(ast, root);
145
+ function binding(name, scope) { while (scope) {
146
+ if (scope.bindings.has(name))
147
+ return scope.bindings.get(name);
148
+ scope = scope.parent;
149
+ } return null; }
150
+ function literal(node, scope, seen = new Set()) {
151
+ if (!node)
152
+ return null;
153
+ if (node.type === 'Literal' && typeof node.value === 'string')
154
+ return node.value;
155
+ if (node.type === 'TemplateLiteral' && !node.expressions.length)
156
+ return node.quasis[0].value.cooked;
157
+ if (node.type === 'BinaryExpression' && node.operator === '+') {
158
+ const a = literal(node.left, scope, seen), b = literal(node.right, scope, seen);
159
+ return a !== null && b !== null ? a + b : null;
160
+ }
161
+ if (node.type === 'Identifier') {
162
+ const b = binding(node.name, scope);
163
+ if (!b?.init || seen.has(b) || b.start > node.start)
164
+ return null;
165
+ return literal(b.init, b.scope, new Set([...seen, b]));
166
+ }
167
+ return null;
168
+ }
169
+ function browserGlobal(node, scope, seen = new Set()) {
170
+ if (node?.type !== 'Identifier') return null;
171
+ const b = binding(node.name, scope);
172
+ if (['window', 'globalThis', 'self'].includes(node.name)) return { shadowed: !!b };
173
+ if (b?.init && !seen.has(b)) return browserGlobal(b.init, b.scope, new Set([...seen, b]));
174
+ return null;
175
+ }
176
+ function area(node, scope, seen = new Set()) {
177
+ if (node?.type === 'Identifier') {
178
+ const b = binding(node.name, scope);
179
+ if (['localStorage', 'sessionStorage'].includes(node.name)) return { name: node.name, shadowed: !!b };
180
+ if (b?.init && !seen.has(b)) return area(b.init, b.scope, new Set([...seen, b]));
181
+ if (b?.destructure) {
182
+ const global = browserGlobal(b.destructure.object, b.scope);
183
+ const property = b.destructure.property;
184
+ const name = property.computed ? literal(property.key, b.scope) : property.key.name;
185
+ if (global && ['localStorage', 'sessionStorage'].includes(name)) return { name, shadowed: global.shadowed || b.destructure.mutable };
186
+ }
187
+ }
188
+ if (node?.type === 'MemberExpression') {
189
+ const global = browserGlobal(node.object, scope);
190
+ const name = node.computed ? literal(node.property, scope) : node.property.name;
191
+ if (global && ['localStorage', 'sessionStorage'].includes(name)) return { name, shadowed: global.shadowed };
192
+ }
193
+ return null;
194
+ }
195
+ function record(node, storage, key, operation, reason = null) {
196
+ let status = 'unaffected', replacement = null;
197
+ if (storage.shadowed || key === null || reason) {
198
+ status = 'unknown';
199
+ reason ??= storage.shadowed ? 'shadowed-storage-reference' : 'dynamic-key';
200
+ }
201
+ else {
202
+ const entries = manifest.keys.filter(e => e.areas.includes(storage.name));
203
+ const entry = entries.find(e => bareKey(e) === key);
204
+ if (entry?.migration && compare(targetSdkVersion, entry.migration.since) >= 0) {
205
+ status = 'incompatible';
206
+ reason = 'bare-migrated-sdk-key';
207
+ replacement = entry.replacement ?? null;
208
+ }
209
+ else if (entry?.scoped && !entry.migration) {
210
+ status = 'unknown';
211
+ reason = 'migration-boundary-unverified';
212
+ }
213
+ else if (entries.some(e => e.scoped && (matchesTemplate(key, e.key) || matchesTemplate(key, bareKey(e)) || key.startsWith(bareKey(e) + '__')) && key !== bareKey(e))) {
214
+ status = 'unknown';
215
+ reason = 'guessed-scoped-sdk-key';
216
+ }
217
+ else
218
+ reason = 'literal-unaffected-key';
219
+ }
220
+ emit(node, { operation, area: storage.name, key, status, reason, replacement });
221
+ }
222
+ const handled = new WeakSet();
223
+ const aliasInitialization = (node, parent) => parent?.type === 'VariableDeclarator' && parent.init === node && parent.id.type === 'Identifier' && binding(parent.id.name, scopes.get(parent))?.init === node;
224
+ const propertyName = (node, parent) => (
225
+ (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed) ||
226
+ (['Property', 'MethodDefinition', 'PropertyDefinition'].includes(parent?.type) && parent.key === node && !parent.computed && !parent.shorthand) ||
227
+ (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(parent?.type) && parent.label === node)
228
+ );
229
+ function visit(node, parent) {
230
+ const scope = scopes.get(node);
231
+ if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') {
232
+ const member = node.callee, storage = area(member.object, scope), method = member.computed ? literal(member.property, scope) : member.property.name;
233
+ if (storage) {
234
+ handled.add(member);
235
+ const operation = { getItem: 'read', setItem: 'write', removeItem: 'remove' }[method];
236
+ record(node, storage, operation ? literal(node.arguments[0], scope) : null, operation ?? 'unknown', operation ? null : 'unsupported-storage-method');
237
+ }
238
+ }
239
+ if (node.type === 'MemberExpression' && !handled.has(node)) {
240
+ const storage = area(node.object, scope);
241
+ if (storage) {
242
+ const operation = parent?.type === 'AssignmentExpression' && parent.left === node ? 'write' : parent?.type === 'UnaryExpression' && parent.operator === 'delete' ? 'remove' : parent?.type === 'UpdateExpression' ? 'write' : 'read';
243
+ const key = node.computed ? literal(node.property, scope) : node.property.name;
244
+ const reserved = ['getItem', 'setItem', 'removeItem', 'clear', 'key', 'length'].includes(key);
245
+ if (key === 'length' && operation === 'read' && !storage.shadowed) {
246
+ emit(node, { operation, area: storage.name, key, status: 'unaffected', reason: 'storage-metadata-read', replacement: null });
247
+ } else {
248
+ record(node, storage, key, operation, reserved ? 'unsupported-storage-member' : null);
249
+ }
250
+ }
251
+ }
252
+ const storage = area(node, scope);
253
+ if (storage && !propertyName(node, parent) && !(parent?.type === 'MemberExpression' && parent.object === node) && !aliasInitialization(node, parent) && !(parent?.type === 'VariableDeclarator' && parent.id === node) && !(parent?.params ?? []).includes(node)) {
254
+ // Passing/destructuring/mutably aliasing the Storage object loses bounded data flow.
255
+ record(node, storage, null, 'unknown', 'storage-object-escape');
256
+ }
257
+ for (const child of children(node))
258
+ visit(child, node);
259
+ }
260
+ visit(ast, null);
261
+ return findings;
262
+ }
263
+ export function scanSdkStorageCompatibility({ cwd = process.cwd(), targetSdkVersion, manifestPath, scope, exclude = [] }) {
264
+ version(targetSdkVersion);
265
+ if (!Array.isArray(scope) || !scope.length)
266
+ throw new Error('At least one explicit --scope is required; include shared script directories explicitly.');
267
+ scope = scope.map(normalizeScope);
268
+ exclude = exclude.map(normalizeScope);
269
+ let root;
270
+ try { root = execFileSync('git', ['-C', resolve(cwd), 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); }
271
+ catch { throw new Error('SDK storage scan target must be a readable Git repository root.'); }
272
+ const rootIdentity = statSync(root);
273
+ const targetIdentity = statSync(cwd);
274
+ if (rootIdentity.dev !== targetIdentity.dev || rootIdentity.ino !== targetIdentity.ino)
275
+ throw new Error('--target must be the Git repository root.');
276
+ const { value: manifest, evidence } = readStorageManifest(manifestPath);
277
+ let tracked;
278
+ try { tracked = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split('\0').filter(Boolean); }
279
+ catch { throw new Error('Cannot inventory tracked Git source files.'); }
280
+ for (const item of scope)
281
+ if (!tracked.some(p => inside(p, item)))
282
+ throw new Error(`Scope has no tracked files: ${item}`);
283
+ const selected = tracked.filter(p => /\.(?:html?|[cm]?js)$/i.test(p) && scope.some(s => inside(p, s)) && !exclude.some(s => inside(p, s))).sort();
284
+ const files = [], findings = [];
285
+ const unknown = (path, reason, detail, line = 1) => findings.push({ path, line, column: 1, operation: 'unknown', area: null, key: null, status: 'unknown', reason, detail, replacement: null });
286
+ if (compare(targetSdkVersion, manifest.supportedSdkVersions.min) < 0 || compare(targetSdkVersion, manifest.supportedSdkVersions.max) > 0)
287
+ unknown(evidence.path, 'target-outside-manifest-range', targetSdkVersion);
288
+ if (!selected.length)
289
+ unknown('.', 'empty-source-scope', 'No tracked HTML or JavaScript files selected.');
290
+ for (const path of selected) {
291
+ const absolute = resolve(root, path);
292
+ let stat;
293
+ try {
294
+ stat = lstatSync(absolute);
295
+ }
296
+ catch (error) {
297
+ unknown(path, 'source-unreadable', error.code ?? error.message);
298
+ continue;
299
+ }
300
+ if (stat.isSymbolicLink() || !realpathSync(absolute).startsWith(realpathSync(root) + sep)) {
301
+ unknown(path, 'unsafe-source-path', 'Symlinks and paths outside target are not scanned.');
302
+ continue;
303
+ }
304
+ let bytes;
305
+ try { bytes = readFileSync(absolute); }
306
+ catch (error) { unknown(path, 'source-unreadable', error.code ?? error.message); continue; }
307
+ files.push({ path, sha256: digest(bytes) });
308
+ if (!isUtf8(bytes)) { unknown(path, 'source-invalid-utf8', 'Source bytes are not valid UTF-8.'); continue; }
309
+ const text = bytes.toString('utf8');
310
+ const analyze = (source, lineOffset = 0, columnOffset = 0) => findings.push(...analyzeStorageJavaScript(source, { path, lineOffset, columnOffset, manifest, targetSdkVersion }));
311
+ if (!/\.html?$/i.test(path)) {
312
+ analyze(text);
313
+ continue;
314
+ }
315
+ const document = parseHtml(text, { sourceCodeLocationInfo: true });
316
+ let baseHref = null;
317
+ function findBase(node) {
318
+ if (baseHref === null && node.tagName === 'base') baseHref = node.attrs?.find(attr => attr.name === 'href')?.value ?? null;
319
+ for (const child of node.childNodes ?? []) findBase(child);
320
+ }
321
+ findBase(document);
322
+ function html(node) {
323
+ if (node.tagName === 'script') {
324
+ const attrs = Object.fromEntries((node.attrs ?? []).map(a => [a.name, a.value]));
325
+ const type = (attrs.type ?? '').trim().toLowerCase();
326
+ if (!type || ['module', 'text/javascript', 'application/javascript', 'text/ecmascript', 'application/ecmascript'].includes(type)) {
327
+ if (attrs.src) {
328
+ if (baseHref !== null && !attrs.src.startsWith('/') && !/^[a-z]+:/i.test(attrs.src)) {
329
+ unknown(path, 'html-base-script-resolution', `Relative script ${attrs.src} resolves against base ${baseHref}; include/review the actual dependency.`, node.sourceCodeLocation?.startLine ?? 1);
330
+ }
331
+ if (!/^(?:[a-z]+:)?\/\//i.test(attrs.src) && !attrs.src.startsWith('data:')) {
332
+ const localSource = attrs.src.split(/[?#]/)[0];
333
+ const sourcePath = posix.normalize(localSource.startsWith('/') ? localSource.slice(1) : posix.join(posix.dirname(path), localSource));
334
+ if (!selected.includes(sourcePath))
335
+ unknown(path, 'shared-script-outside-scope', sourcePath, node.sourceCodeLocation?.startLine ?? 1);
336
+ }
337
+ }
338
+ else if (node.sourceCodeLocation?.startTag) {
339
+ const start = node.sourceCodeLocation.startTag.endOffset, end = node.sourceCodeLocation.endTag?.startOffset ?? node.sourceCodeLocation.endOffset;
340
+ const before = text.slice(0, start);
341
+ const lines = before.split('\n');
342
+ analyze(text.slice(start, end), lines.length - 1, lines.at(-1).length);
343
+ }
344
+ }
345
+ }
346
+ for (const child of node.childNodes ?? [])
347
+ html(child);
348
+ if (node.content)
349
+ html(node.content);
350
+ }
351
+ html(document);
352
+ }
353
+ const status = findings.some(f => f.status === 'incompatible') ? 'incompatible' : findings.some(f => f.status === 'unknown') ? 'unknown' : 'source-compatible';
354
+ return { schemaVersion: 1, targetSdkVersion, manifest: evidence, scope, exclude, files, findings, status, evidenceBoundary: 'Static tracked source compatibility only; no browser, order, or analytics proof.' };
355
+ }
356
+ export function formatStorageCompatibilityReport(result) {
357
+ const counts = status => result.findings.filter(f => f.status === status).length;
358
+ return [`SDK storage compatibility: ${result.status} (target ${result.targetSdkVersion})`, `${result.files.length} tracked source files; ${counts('incompatible')} incompatible, ${counts('unknown')} unknown, ${counts('unaffected')} unaffected storage accesses.`, `Manifest ${result.manifest.sha256} (${result.manifest.source.verification})`, ...result.findings.filter(f => f.status !== 'unaffected').map(f => `${f.path}:${f.line}:${f.column} ${f.status}: ${f.reason}${f.key ? ` (${f.key})` : ''}${f.detail ? ` — ${f.detail}` : ''}`), result.evidenceBoundary].join('\n');
359
+ }