@haystackeditor/cli 0.24.1 → 0.25.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 (51) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +134 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +74 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next-build.js +113 -0
  10. package/dist/capture/adapters/next.js +494 -0
  11. package/dist/capture/adapters/nuxt.js +199 -0
  12. package/dist/capture/adapters/rails.js +178 -0
  13. package/dist/capture/adapters/react-router.js +439 -0
  14. package/dist/capture/adapters/sveltekit.js +109 -0
  15. package/dist/capture/adapters/types.js +4 -0
  16. package/dist/capture/adapters/vite.js +135 -0
  17. package/dist/capture/app-config.js +107 -0
  18. package/dist/capture/consent.js +127 -0
  19. package/dist/capture/csp.js +332 -0
  20. package/dist/capture/html.js +74 -0
  21. package/dist/capture/js-ast.js +400 -0
  22. package/dist/capture/manifest.js +95 -0
  23. package/dist/capture/project.js +177 -0
  24. package/dist/capture/route-pattern.js +119 -0
  25. package/dist/capture/script-release.js +47 -0
  26. package/dist/capture/tag.js +74 -0
  27. package/dist/capture/url-rewrites.js +232 -0
  28. package/dist/capture-step.js +56 -0
  29. package/dist/commands/capture-brief.js +92 -0
  30. package/dist/commands/capture-contract.js +46 -0
  31. package/dist/commands/capture-manifest.js +86 -0
  32. package/dist/commands/init-capture.js +426 -0
  33. package/dist/commands/init-telemetry.js +1028 -0
  34. package/dist/commands/init.js +78 -5
  35. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  36. package/dist/commands/server-telemetry-contract.js +127 -0
  37. package/dist/commands/telemetry-token.js +238 -0
  38. package/dist/commands/telemetry.d.ts +161 -8
  39. package/dist/commands/telemetry.js +940 -158
  40. package/dist/commands/verify-onboarding.js +21 -1
  41. package/dist/commands/verify.js +56 -9
  42. package/dist/index.js +85 -6
  43. package/dist/schema.js +2 -2
  44. package/dist/telemetry/next-loader.cjs +66 -9
  45. package/dist/telemetry/next.d.ts +11 -3
  46. package/dist/telemetry/next.js +95 -15
  47. package/dist/telemetry/typed-source.d.ts +47 -0
  48. package/dist/telemetry/typed-source.js +379 -0
  49. package/package.json +4 -2
  50. package/schemas/init.v1.json +63 -4
  51. package/schemas/pre-verify.v1.json +60 -3
@@ -1,10 +1,13 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
3
4
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { createRequire } from 'node:module';
6
7
  import chalk from 'chalk';
7
8
  import fg from 'fast-glob';
9
+ import { SERVER_FIELDS_PER_SITE, SETTINGS_FILE_PATH, SETTINGS_PROPOSAL_PATH, serverRuntimeContract, settingLiteralProblem, settingRefusal, } from './server-telemetry-contract.js';
10
+ import { buildTypeIndex, declaredFields, literalDomain, memberType, } from '../telemetry/typed-source.js';
8
11
  // @babel/core intentionally has no bundled declarations. Keep this small
9
12
  // build-transform boundary dynamic instead of leaking `any` into CLI contracts.
10
13
  // It is loaded on first use, not at module top: loading it costs tens of
@@ -31,11 +34,15 @@ const RUNTIME_ESM_FILENAME = 'runtime.mjs';
31
34
  const MANIFEST_FILENAME = 'instrumentation.json';
32
35
  const RUNTIME_CONTROL_CHANNEL_PLACEHOLDER = '__HAYSTACK_TELEMETRY_CONTROL_CHANNEL__';
33
36
  const RUNTIME_INTEGRITY_PLACEHOLDER = '__HAYSTACK_TELEMETRY_RUNTIME_INTEGRITY__';
34
- const MANIFEST_SCHEMA_VERSION = 'haystack-node-telemetry-instrumentation-v3';
37
+ const RUNTIME_CONTRACT_PLACEHOLDER = '/* __HAYSTACK_SERVER_CONTRACT__ */ null';
38
+ // v4: sampler 5's sites (declared fields from typed source, approved setting literals).
39
+ const MANIFEST_SCHEMA_VERSION = 'haystack-node-telemetry-instrumentation-v4';
35
40
  const MAX_INSTRUMENTED_SITES = 50_000;
36
41
  const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs'];
42
+ const TYPED_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
37
43
  const SAFE_SOURCE_PATH = /^[^#|\r\n]+$/;
38
44
  const SAFE_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]{0,127}$/;
45
+ const factsKey = (qualifiedName, label) => `${qualifiedName}\0${label}`;
39
46
  function generatedBody(code) {
40
47
  const body = code.startsWith('#!')
41
48
  ? code.slice(code.indexOf('\n') + 1)
@@ -137,8 +144,9 @@ function enclosingFunctionName(path) {
137
144
  const fn = path.getFunctionParent();
138
145
  return fn ? functionName(fn) : '(top)';
139
146
  }
140
- function sourceFieldsForParameter(fnPath, name) {
141
- const binding = fnPath.scope.getBinding(name);
147
+ /** Field names the code reads on a binding (`value.field`, `value?.['field']`): source-declared, never enumerated. */
148
+ function sourceFieldsForName(scope, name) {
149
+ const binding = scope.getBinding(name);
142
150
  if (!binding)
143
151
  return [];
144
152
  const fields = new Set();
@@ -153,7 +161,34 @@ function sourceFieldsForParameter(fnPath, name) {
153
161
  else if (member.node.computed && typeof property?.value === 'string')
154
162
  fields.add(property.value);
155
163
  }
156
- return [...fields].filter(field => /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/.test(field)).sort().slice(0, 48);
164
+ return [...fields].filter(field => /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/.test(field)).sort().slice(0, SERVER_FIELDS_PER_SITE);
165
+ }
166
+ /** Rule 9(b): the fields the code reads first (they are what its behaviour depends on), then the fields its type
167
+ * declares, at most SERVER_FIELDS_PER_SITE. */
168
+ function mergeFields(read, declared) {
169
+ const fields = [...read];
170
+ for (const field of declared ?? []) {
171
+ if (!fields.includes(field) && /^[A-Za-z_$][A-Za-z0-9_$]{0,63}$/.test(field))
172
+ fields.push(field);
173
+ }
174
+ return fields.slice(0, SERVER_FIELDS_PER_SITE);
175
+ }
176
+ /** A binding whose initializer is an anonymous function or class takes the binding's name (`const f = () => {}` has
177
+ * `f.name === 'f'`); wrapping the initializer in a probe call would take that name away, and with it stack frames. */
178
+ function namedByBinding(t, init) {
179
+ let node = init;
180
+ while (node && (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node) || t.isTSNonNullExpression(node) ||
181
+ t.isTSTypeAssertion(node) || t.isParenthesizedExpression(node) || node.type === 'TypeCastExpression')) {
182
+ node = node.expression;
183
+ }
184
+ return t.isArrowFunctionExpression(node) || (t.isFunctionExpression(node) && !node.id) ||
185
+ (t.isClassExpression(node) && !node.id);
186
+ }
187
+ /** Next route segment config: `export const runtime = 'edge'` makes the module an edge bundle's. */
188
+ function declaresEdgeRuntime(program) {
189
+ return (program.body ?? []).some((statement) => statement.type === 'ExportNamedDeclaration' && statement.declaration?.type === 'VariableDeclaration' &&
190
+ statement.declaration.declarations.some((declarator) => declarator.id?.type === 'Identifier' && declarator.id.name === 'runtime' &&
191
+ declarator.init?.type === 'StringLiteral' && ['edge', 'experimental-edge'].includes(declarator.init.value)));
157
192
  }
158
193
  function sourceFieldsForReturnedObject(node) {
159
194
  if (node?.type !== 'ObjectExpression')
@@ -194,22 +229,43 @@ function parameterBindings(t, parameter) {
194
229
  function optionalProbeCall(t, probeName, value) {
195
230
  return t.callExpression(t.identifier(probeName), [value]);
196
231
  }
197
- function capturedSiteProbeDeclaration(t, capturedName, runtimeControlName, passthroughName, runtimeIntegrity, runtimeProbeName, site) {
198
- return t.variableDeclaration('const', [t.variableDeclarator(t.identifier(capturedName), t.callExpression(t.functionExpression(null, [], t.blockStatement([
199
- t.tryStatement(t.blockStatement([
200
- t.variableDeclaration('const', [t.variableDeclarator(t.identifier('selected'), t.callExpression(t.memberExpression(t.identifier(runtimeControlName), t.identifier('safeSelectSiteProbe')), [
201
- t.stringLiteral(runtimeIntegrity),
202
- t.stringLiteral(runtimeProbeName),
203
- t.stringLiteral(site),
204
- t.identifier(passthroughName),
205
- ]))]),
206
- t.returnStatement(t.conditionalExpression(t.binaryExpression('===', t.unaryExpression('typeof', t.identifier('selected')), t.stringLiteral('function')), t.identifier('selected'), t.identifier(passthroughName))),
207
- ]), t.catchClause(t.identifier('_error'), t.blockStatement([
208
- t.returnStatement(t.identifier(passthroughName)),
209
- ]))),
210
- ])), []))]);
232
+ /**
233
+ * Rule 13(b), same program: the generated declarations are `var`s and function declarations only, so they are hoisted
234
+ * with the module. A function of this module that another module calls before this module's body has run (a cyclic
235
+ * import) reaches its probes before any `const` here would be initialized; a hoisted probe binds the runtime on its first
236
+ * call instead, and while the runtime cannot be reached yet (an ESM import still in its temporal dead zone) it passes the
237
+ * value through and tries again on the next call.
238
+ *
239
+ * var control, s0, s1;
240
+ * function bind(name, site) { ... resolves control once, then the site's probe ... }
241
+ * function site0(value) { return (s0 || (s0 = bind("__hstValue", "<site>")) || sitePass)(value); }
242
+ */
243
+ function lazySiteProbeDeclarations(t, names, controlExpression, runtimeIntegrity, sites) {
244
+ const babel = loadBabel();
245
+ const bind = babel.template.statement(`function ${names.bind}(name, site) {
246
+ if (${names.control} === undefined) {
247
+ try { ${names.control} = %%CONTROL%%; } catch (_error) { return null; }
248
+ }
249
+ try {
250
+ const selected = ${names.control}.safeSelectSiteProbe(${JSON.stringify(runtimeIntegrity)}, name, site, ${names.sitePass});
251
+ return typeof selected === 'function' ? selected : ${names.sitePass};
252
+ } catch (_error) { return ${names.sitePass}; }
253
+ }`, { syntacticPlaceholders: true })({ CONTROL: controlExpression });
254
+ return [
255
+ t.variableDeclaration('var', [
256
+ t.variableDeclarator(t.identifier(names.control)),
257
+ ...sites.map(site => t.variableDeclarator(t.identifier(site.selected))),
258
+ ]),
259
+ bind,
260
+ ...sites.map(site => t.functionDeclaration(t.identifier(site.fn), [t.identifier('value')], t.blockStatement([t.returnStatement(t.callExpression(t.logicalExpression('||', t.logicalExpression('||', t.identifier(site.selected), t.assignmentExpression('=', t.identifier(site.selected), t.callExpression(t.identifier(names.bind), [
261
+ t.stringLiteral(site.probeName),
262
+ t.stringLiteral(site.site),
263
+ ]))), t.identifier(names.sitePass)), [t.identifier('value')]))]))),
264
+ ];
211
265
  }
212
- function runtimeControlDeclarations(t, controlName, helperName, specifier, moduleType, runtimeGuardChannel, runtimeBlockedChannel) {
266
+ /** Where generated probes find the runtime: the hash-verified ESM import, or the capability the bootstrap put on the
267
+ * real process object (CommonJS and bundled code), with a pass-through fallback. Evaluated on a probe's first call. */
268
+ function runtimeControl(t, controlName, helperName, specifier, moduleType, runtimeGuardChannel, runtimeBlockedChannel) {
213
269
  const fallbackModule = (processHostName) => t.objectExpression([t.objectMethod('method', t.identifier('safeSelectProbe'), [], t.blockStatement([t.returnStatement(t.identifier(helperName))])), t.objectMethod('method', t.identifier('safeSelectSiteProbe'), [
214
270
  t.identifier('_integrity'),
215
271
  t.identifier('_name'),
@@ -242,29 +298,30 @@ function runtimeControlDeclarations(t, controlName, helperName, specifier, modul
242
298
  ]))),
243
299
  ] : [t.returnStatement(t.identifier('passthrough'))]))]);
244
300
  if (moduleType === 'module') {
245
- return [
246
- t.importDeclaration([t.importDefaultSpecifier(t.identifier(`${controlName}Imported`))], t.stringLiteral(specifier)),
247
- t.variableDeclaration('const', [t.variableDeclarator(t.identifier(controlName), t.conditionalExpression(t.binaryExpression('===', t.unaryExpression('typeof', t.memberExpression(t.identifier(`${controlName}Imported`), t.identifier('safeSelectProbe'))), t.stringLiteral('function')), t.identifier(`${controlName}Imported`), fallbackModule()))]),
248
- ];
301
+ return {
302
+ imports: [t.importDeclaration([t.importDefaultSpecifier(t.identifier(`${controlName}Imported`))], t.stringLiteral(specifier))],
303
+ expression: t.conditionalExpression(t.binaryExpression('===', t.unaryExpression('typeof', t.memberExpression(t.identifier(`${controlName}Imported`), t.identifier('safeSelectProbe'))), t.stringLiteral('function')), t.identifier(`${controlName}Imported`), fallbackModule()),
304
+ };
249
305
  }
250
- return [
251
- t.variableDeclaration('const', [t.variableDeclarator(t.identifier(controlName), t.callExpression(t.functionExpression(null, [], t.blockStatement([
252
- t.tryStatement(t.blockStatement([
253
- t.variableDeclaration('const', [t.variableDeclarator(t.identifier('processHost'),
254
- // A bundled module must not name node:process: the bundler
255
- // would resolve it as a module of its own. The global is the
256
- // same object in a Node server.
257
- moduleType === 'bundled'
258
- ? t.memberExpression(t.identifier('globalThis'), t.identifier('process'))
259
- : t.callExpression(t.identifier('require'), [t.stringLiteral('node:process')]))]),
260
- t.ifStatement(t.binaryExpression('in', t.stringLiteral(runtimeBlockedChannel), t.identifier('processHost')), t.returnStatement(fallbackModule('processHost'))),
261
- t.variableDeclaration('const', [t.variableDeclarator(t.identifier('loaded'), t.memberExpression(t.identifier('processHost'), t.stringLiteral(runtimeGuardChannel), true))]),
262
- t.returnStatement(t.conditionalExpression(t.logicalExpression('&&', t.identifier('loaded'), t.binaryExpression('===', t.unaryExpression('typeof', t.memberExpression(t.identifier('loaded'), t.identifier('safeSelectProbe'))), t.stringLiteral('function'))), t.identifier('loaded'), fallbackModule('processHost'))),
263
- ]), t.catchClause(t.identifier('_error'), t.blockStatement([
264
- t.returnStatement(fallbackModule()),
265
- ]))),
266
- ])), []))]),
267
- ];
306
+ return {
307
+ imports: [],
308
+ expression: t.callExpression(t.functionExpression(null, [], t.blockStatement([
309
+ t.tryStatement(t.blockStatement([
310
+ t.variableDeclaration('const', [t.variableDeclarator(t.identifier('processHost'),
311
+ // A bundled module must not name node:process: the bundler
312
+ // would resolve it as a module of its own. The global is the
313
+ // same object in a Node server.
314
+ moduleType === 'bundled'
315
+ ? t.memberExpression(t.identifier('globalThis'), t.identifier('process'))
316
+ : t.callExpression(t.identifier('require'), [t.stringLiteral('node:process')]))]),
317
+ t.ifStatement(t.binaryExpression('in', t.stringLiteral(runtimeBlockedChannel), t.identifier('processHost')), t.returnStatement(fallbackModule('processHost'))),
318
+ t.variableDeclaration('const', [t.variableDeclarator(t.identifier('loaded'), t.memberExpression(t.identifier('processHost'), t.stringLiteral(runtimeGuardChannel), true))]),
319
+ t.returnStatement(t.conditionalExpression(t.logicalExpression('&&', t.identifier('loaded'), t.binaryExpression('===', t.unaryExpression('typeof', t.memberExpression(t.identifier('loaded'), t.identifier('safeSelectProbe'))), t.stringLiteral('function'))), t.identifier('loaded'), fallbackModule('processHost'))),
320
+ ]), t.catchClause(t.identifier('_error'), t.blockStatement([
321
+ t.returnStatement(fallbackModule()),
322
+ ]))),
323
+ ])), []),
324
+ };
268
325
  }
269
326
  function passthroughHelper(t, helperName) {
270
327
  return t.functionDeclaration(t.identifier(helperName), [t.identifier('_site'), t.identifier('value')], t.blockStatement([t.returnStatement(t.identifier('value'))]));
@@ -272,10 +329,13 @@ function passthroughHelper(t, helperName) {
272
329
  function sitePassthroughHelper(t, helperName) {
273
330
  return t.functionDeclaration(t.identifier(helperName), [t.identifier('value')], t.blockStatement([t.returnStatement(t.identifier('value'))]));
274
331
  }
275
- function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, controlChannel, runtimeIntegrity, includedQualifiedNames, parserPlugins, recordSourcePositions, skipDirective, moduleScopeProbes, }) {
332
+ /** A selection the caller asked for cannot be honoured: the caller's error, not a file this pass could not transform. */
333
+ class SymbolSelectionError extends Error {
334
+ }
335
+ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, controlChannel, runtimeIntegrity, includedQualifiedNames, parserPlugins, recordSourcePositions, skipDirective, skipEdgeRuntime, moduleScopeProbes, siteFacts, sourceMap, bootstrap, }) {
276
336
  if (hasGeneratedInstrumentation(code))
277
337
  return null;
278
- let skippedByDirective = false;
338
+ let skippedModule = false;
279
339
  const probes = { branches: 0, parameters: 0, bindings: 0, returns: 0, throws: 0 };
280
340
  const ordinals = new WeakMap();
281
341
  const functionIdentities = new WeakMap();
@@ -286,6 +346,7 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
286
346
  let helperName = '';
287
347
  let siteHelperName = '';
288
348
  let runtimeControlName = '';
349
+ let bindName = '';
289
350
  let skipFileProbes = false;
290
351
  const nextOrdinal = (path, kind) => {
291
352
  const owner = path.getFunctionParent()?.node ?? path.findParent((candidate) => candidate.isProgram())?.node;
@@ -308,11 +369,10 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
308
369
  // Candidate nodes for the site's source position, most specific first.
309
370
  // Nodes this pass synthesizes (the return ensureBlock wraps around an
310
371
  // arrow's expression body) have no location; the next candidate does.
311
- positionNodes, label, allowedFields = []) => {
372
+ positionNodes, label, allowedFields = [], settings = null) => {
312
373
  const targetId = `site-${shortHash([sourcePath, owner, kind, structuralKey])}`;
313
374
  const value = `${sourcePath}#${fn}:${targetId}${label ? `(${label})` : ''}`;
314
- // Leave room under the ingestion endpoint's 512-character key cap for a
315
- // static field label and its longest value-shape outcome.
375
+ // Leave room under the ingestion endpoint's 512-character key cap.
316
376
  if (value.length > 360)
317
377
  throw new Error(`Telemetry site exceeds the ingestion key limit in ${sourcePath}`);
318
378
  let position = null;
@@ -332,6 +392,7 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
332
392
  structural_key: structuralKey,
333
393
  ...(label ? { label } : {}),
334
394
  allowed_fields: [...allowedFields],
395
+ ...(settings && settings.length > 0 ? { settings: [...settings] } : {}),
335
396
  ...(position ?? {}),
336
397
  });
337
398
  return value;
@@ -340,10 +401,12 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
340
401
  const siteProbeName = (path, runtimeProbeName, siteKey) => {
341
402
  const existing = siteProbeNames.get(siteKey);
342
403
  if (existing)
343
- return existing;
344
- const name = path.scope.getProgramParent().generateUidIdentifier(`__haystackTelemetrySite_${siteProbeNames.size}`).name;
345
- siteProbeNames.set(siteKey, name);
346
- return name;
404
+ return existing.fn;
405
+ const program = path.scope.getProgramParent();
406
+ const fn = program.generateUidIdentifier(`__haystackTelemetrySite_${siteProbeNames.size}`).name;
407
+ const selected = program.generateUidIdentifier(`__haystackTelemetrySelected_${siteProbeNames.size}`).name;
408
+ siteProbeNames.set(siteKey, { fn, selected, probeName: runtimeProbeName });
409
+ return fn;
347
410
  };
348
411
  const insideAccessor = (path) => {
349
412
  const fn = path.getFunctionParent();
@@ -376,14 +439,15 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
376
439
  visitor: {
377
440
  Program: {
378
441
  enter(path) {
379
- if (skipDirective && (path.node.directives ?? []).some((directive) => directive.value?.value === skipDirective)) {
380
- skippedByDirective = true;
442
+ if ((skipDirective && (path.node.directives ?? []).some((directive) => directive.value?.value === skipDirective)) || (skipEdgeRuntime && declaresEdgeRuntime(path.node))) {
443
+ skippedModule = true;
381
444
  path.stop();
382
445
  return;
383
446
  }
384
447
  helperName = path.scope.generateUidIdentifier('__haystackTelemetryPassthrough').name;
385
448
  siteHelperName = path.scope.generateUidIdentifier('__haystackTelemetrySitePassthrough').name;
386
449
  runtimeControlName = path.scope.generateUidIdentifier('__haystackTelemetryControl').name;
450
+ bindName = path.scope.generateUidIdentifier('__haystackTelemetryBind').name;
387
451
  path.traverse({
388
452
  CallExpression(candidate) {
389
453
  if (candidate.get('callee').isIdentifier?.({ name: 'eval' })) {
@@ -400,21 +464,27 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
400
464
  exit(path) {
401
465
  const optionalProbeCount = probes.branches + probes.bindings + probes.returns + probes.throws;
402
466
  const totalProbeCount = optionalProbeCount + probes.parameters;
403
- if (skipFileProbes || totalProbeCount === 0)
467
+ // The entry's bootstrap goes first, after the directive prologue (a customer's "use strict" stays first), in
468
+ // the same single insertion as the probe declarations: inserted statements are skipped, never instrumented.
469
+ const bootstrapStatements = bootstrap
470
+ ? loadBabel().parseSync(bootstrap, { sourceType: 'unambiguous' })?.program?.body ?? []
471
+ : [];
472
+ if (bootstrap && bootstrapStatements.length === 0)
473
+ throw new Error('Telemetry bootstrap produced no statements');
474
+ if (skipFileProbes || totalProbeCount === 0) {
475
+ if (bootstrapStatements.length > 0) {
476
+ for (const inserted of path.unshiftContainer('body', bootstrapStatements))
477
+ inserted.skip();
478
+ }
404
479
  return;
480
+ }
481
+ const control = runtimeControl(t, runtimeControlName, helperName, runtimeSpecifier, moduleType, `${controlChannel}_runtimeGuard`, `${controlChannel}_runtimeBlocked`);
405
482
  const declarations = [
406
- ...runtimeControlDeclarations(t, runtimeControlName, helperName, runtimeSpecifier, moduleType, `${controlChannel}_runtimeGuard`, `${controlChannel}_runtimeBlocked`),
483
+ ...bootstrapStatements,
484
+ ...control.imports,
407
485
  passthroughHelper(t, helperName),
408
486
  sitePassthroughHelper(t, siteHelperName),
409
- ...[...siteProbeNames.entries()].map(([siteKey, name]) => {
410
- const probeKind = sites.get(siteKey)?.probe_kind;
411
- const runtimeProbeName = probeKind === 'condition'
412
- ? '__hstBranch'
413
- : probeKind === 'throw'
414
- ? '__hstThrow'
415
- : '__hstValue';
416
- return capturedSiteProbeDeclaration(t, name, runtimeControlName, siteHelperName, runtimeIntegrity, runtimeProbeName, siteKey);
417
- }),
487
+ ...lazySiteProbeDeclarations(t, { control: runtimeControlName, bind: bindName, sitePass: siteHelperName }, control.expression, runtimeIntegrity, [...siteProbeNames.entries()].map(([siteKey, names]) => ({ ...names, site: siteKey }))),
418
488
  ];
419
489
  const paths = path.unshiftContainer('body', declarations);
420
490
  for (const inserted of paths)
@@ -445,11 +515,13 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
445
515
  !selectedFunction(path))
446
516
  return;
447
517
  const fn = functionName(path);
518
+ const qualifiedName = qualifiedFunctionName(path);
448
519
  const statements = [];
449
520
  for (const [parameterIndex, parameter] of path.node.params.entries()) {
450
521
  for (const [bindingIndex, identifier] of parameterBindings(t, parameter).entries()) {
451
- const fields = sourceFieldsForParameter(path, identifier.name);
452
- const parameterSite = site(fn, qualifiedFunctionName(path), ownerIdentity(path), 'parameter', `${parameterIndex}:${bindingIndex}:${identifier.name}`, [identifier, parameter, path.node], `parameter:${identifier.name}`, fields);
522
+ const label = `parameter:${identifier.name}`;
523
+ const typed = siteFacts?.(qualifiedName, label) ?? null;
524
+ const parameterSite = site(fn, qualifiedName, ownerIdentity(path), 'parameter', `${parameterIndex}:${bindingIndex}:${identifier.name}`, [identifier, parameter, path.node], label, mergeFields(sourceFieldsForName(path.scope, identifier.name), typed?.fields), typed?.settings ?? null);
453
525
  statements.push(t.expressionStatement(optionalProbeCall(t, siteProbeName(path, '__hstValue', parameterSite), t.identifier(identifier.name))));
454
526
  probes.parameters++;
455
527
  }
@@ -477,11 +549,16 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
477
549
  return;
478
550
  if (!t.isIdentifier(path.node.id) || !path.node.init || !t.isExpression(path.node.init))
479
551
  return;
552
+ if (namedByBinding(t, path.node.init))
553
+ return;
554
+ const name = path.node.id.name;
480
555
  const fn = enclosingFunctionName(path);
481
556
  const functionPath = path.getFunctionParent();
482
557
  const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
483
- const ordinal = nextOrdinal(path, `binding:${path.node.id.name}`);
484
- const valueSite = site(fn, qualifiedName, ownerIdentity(path), 'binding', `${path.node.id.name}:${ordinal}`, [path.node], `binding:${path.node.id.name}`);
558
+ const ordinal = nextOrdinal(path, `binding:${name}`);
559
+ const label = `binding:${name}`;
560
+ const typed = siteFacts?.(qualifiedName, label) ?? null;
561
+ const valueSite = site(fn, qualifiedName, ownerIdentity(path), 'binding', `${name}:${ordinal}`, [path.node], label, mergeFields(sourceFieldsForName(path.scope, name), typed?.fields), typed?.settings ?? null);
485
562
  path.get('init').replaceWith(optionalProbeCall(t, siteProbeName(path, '__hstValue', valueSite), path.node.init));
486
563
  path.get('init').skip();
487
564
  probes.bindings++;
@@ -536,8 +613,18 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
536
613
  code: true,
537
614
  retainLines: true,
538
615
  compact: false,
616
+ // Rule 13(b): the map from the instrumented text back through every earlier transform. An absent input map is
617
+ // never looked for in the file (the caller decides), so Babel reads nothing on its own.
618
+ ...(sourceMap
619
+ ? {
620
+ filename: sourceMap.filename,
621
+ sourceFileName: sourceMap.filename,
622
+ sourceMaps: true,
623
+ inputSourceMap: sourceMap.input ?? false,
624
+ }
625
+ : { sourceMaps: false, inputSourceMap: false }),
539
626
  });
540
- if (skippedByDirective)
627
+ if (skippedModule)
541
628
  return null;
542
629
  if (!transformed?.code)
543
630
  throw new Error(`Instrumentation produced no code for ${sourcePath}`);
@@ -548,14 +635,150 @@ function instrumentSource(code, { sourcePath, runtimeSpecifier, moduleType, cont
548
635
  .filter(([, owners]) => owners.size > 1)
549
636
  .map(([qualifiedName]) => `${sourcePath}#${qualifiedName}`);
550
637
  if (ambiguousSelections.length > 0) {
551
- throw new Error(`Telemetry exact symbol selection is ambiguous: ${ambiguousSelections.join(', ')}; select a symbol with a unique stable identity`);
638
+ throw new SymbolSelectionError(`Telemetry exact symbol selection is ambiguous: ${ambiguousSelections.join(', ')}; select a symbol with a unique stable identity`);
552
639
  }
640
+ const marker = `${INSTRUMENTED_MARKER_COMMENT} `;
641
+ const markerLine = transformed.code.startsWith('#!') ? 1 : 0;
553
642
  return {
554
- code: prefixAfterShebang(transformed.code, `${INSTRUMENTED_MARKER_COMMENT} `),
643
+ code: prefixAfterShebang(transformed.code, marker),
644
+ map: transformed.map ? shiftGeneratedColumns(transformed.map, markerLine, marker.length) : null,
555
645
  probes,
556
646
  sites: [...sites.values()].sort((left, right) => left.site_id.localeCompare(right.site_id)),
557
647
  };
558
648
  }
649
+ const BASE64_DIGITS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
650
+ /** The marker comment is prefixed after generation, so the map's first segment on that line moves right by its length
651
+ * (a line's first generated column is absolute; every later segment on the line is relative to it). */
652
+ function shiftGeneratedColumns(map, line, by) {
653
+ if (typeof map.mappings !== 'string')
654
+ return map;
655
+ const lines = map.mappings.split(';');
656
+ const target = lines[line];
657
+ if (!target)
658
+ return map;
659
+ let value = 0;
660
+ let shift = 0;
661
+ let index = 0;
662
+ for (; index < target.length; index++) {
663
+ const digit = BASE64_DIGITS.indexOf(target[index]);
664
+ if (digit < 0)
665
+ return map;
666
+ value += (digit & 31) << shift;
667
+ shift += 5;
668
+ if ((digit & 32) === 0)
669
+ break;
670
+ }
671
+ const column = (value & 1 ? -(value >>> 1) : value >>> 1) + by;
672
+ let encoded = '';
673
+ let rest = column < 0 ? ((-column) << 1) | 1 : column << 1;
674
+ do {
675
+ let digit = rest & 31;
676
+ rest >>>= 5;
677
+ if (rest > 0)
678
+ digit |= 32;
679
+ encoded += BASE64_DIGITS[digit];
680
+ } while (rest > 0);
681
+ lines[line] = encoded + target.slice(index + 1);
682
+ return { ...map, mappings: lines.join(';') };
683
+ }
684
+ /** One parameter's bindings with the type each is declared with (a destructured binding: its property's type), named
685
+ * exactly as parameterBindings names the sites. */
686
+ function typedParameterBindings(parameter, annotation, index, resolve) {
687
+ if (!parameter)
688
+ return [];
689
+ if (parameter.type === 'Identifier')
690
+ return [{ name: parameter.name, annotation: annotation ?? parameter.typeAnnotation, index, resolve }];
691
+ if (parameter.type === 'AssignmentPattern')
692
+ return typedParameterBindings(parameter.left, annotation ?? parameter.left?.typeAnnotation, index, resolve);
693
+ if (parameter.type === 'RestElement')
694
+ return typedParameterBindings(parameter.argument, null, index, resolve).map(binding => ({ ...binding, annotation: null }));
695
+ if (parameter.type === 'ObjectPattern') {
696
+ const own = annotation ?? parameter.typeAnnotation;
697
+ return parameter.properties.flatMap((property) => {
698
+ if (property.type === 'RestElement')
699
+ return typedParameterBindings(property.argument, null, index, resolve).map(binding => ({ ...binding, annotation: null }));
700
+ if (property.type !== 'ObjectProperty')
701
+ return [];
702
+ const key = !property.computed && property.key?.type === 'Identifier' ? property.key.name
703
+ : property.key?.type === 'StringLiteral' ? property.key.value : null;
704
+ const member = own && key !== null ? memberType(own, key, index, resolve) : null;
705
+ return typedParameterBindings(property.value, member?.annotation ?? null, member?.index ?? index, member?.resolve ?? resolve)
706
+ .map(binding => member ? binding : { ...binding, annotation: null });
707
+ });
708
+ }
709
+ if (parameter.type === 'ArrayPattern') {
710
+ return parameter.elements.flatMap((element) => typedParameterBindings(element, null, index, resolve))
711
+ .map((binding) => ({ ...binding, annotation: null }));
712
+ }
713
+ return [];
714
+ }
715
+ /** `x as T` and `x satisfies T` declare a binding's type when it has no annotation (never `as const`). */
716
+ function assertedType(init) {
717
+ let node = init;
718
+ while (node?.type === 'TSSatisfiesExpression' || node?.type === 'TSAsExpression') {
719
+ const annotation = node.typeAnnotation;
720
+ if (!(annotation?.type === 'TSTypeReference' && annotation.typeName?.type === 'Identifier' && annotation.typeName.name === 'const')) {
721
+ return annotation;
722
+ }
723
+ node = node.expression;
724
+ }
725
+ return null;
726
+ }
727
+ /**
728
+ * Rule 9(b)/(c): per site of a TYPED source file (keyed by the site's function and label exactly as instrumentSource
729
+ * names them), the fields its type declares and its literal domain. Compiled output loses the types, so the compiled-
730
+ * output path reads the original TypeScript beside it and joins by this key; the bundler path reads the module it
731
+ * instruments. A key the file declares twice with different facts is ambiguous and keeps neither.
732
+ */
733
+ export function collectTypedSiteFacts(code, parserPlugins, resolve) {
734
+ const facts = new Map();
735
+ const record = (key, annotation, index, resolveImport) => {
736
+ const domain = annotation ? literalDomain(annotation, index, resolveImport) : null;
737
+ const value = {
738
+ fields: annotation ? declaredFields(annotation, index, resolveImport) : [],
739
+ domain: domain?.literals ?? null,
740
+ typeNames: domain?.typeNames ?? [],
741
+ };
742
+ const existing = facts.get(key);
743
+ if (existing === undefined)
744
+ facts.set(key, value);
745
+ else if ('ambiguous' in existing || JSON.stringify(existing) !== JSON.stringify(value))
746
+ facts.set(key, { ambiguous: true });
747
+ };
748
+ let index = buildTypeIndex(null);
749
+ loadBabel().transformSync(code, {
750
+ parserOpts: { sourceType: 'unambiguous', plugins: parserPlugins },
751
+ plugins: [() => ({
752
+ visitor: {
753
+ Program(path) {
754
+ index = buildTypeIndex(path.node);
755
+ },
756
+ Function(path) {
757
+ if (path.node.kind === 'get' || path.node.kind === 'set')
758
+ return;
759
+ const qualifiedName = qualifiedFunctionName(path);
760
+ for (const parameter of path.node.params) {
761
+ for (const binding of typedParameterBindings(parameter, null, index, resolve)) {
762
+ record(factsKey(qualifiedName, `parameter:${binding.name}`), binding.annotation, binding.index, binding.resolve);
763
+ }
764
+ }
765
+ },
766
+ VariableDeclarator(path) {
767
+ if (path.node.id?.type !== 'Identifier' || !path.node.init)
768
+ return;
769
+ const functionPath = path.getFunctionParent();
770
+ const qualifiedName = functionPath ? qualifiedFunctionName(functionPath) : '(top)';
771
+ record(factsKey(qualifiedName, `binding:${path.node.id.name}`), path.node.id.typeAnnotation ?? assertedType(path.node.init), index, resolve);
772
+ },
773
+ },
774
+ })],
775
+ babelrc: false,
776
+ configFile: false,
777
+ ast: false,
778
+ code: false,
779
+ });
780
+ return facts;
781
+ }
559
782
  function prefixAfterShebang(code, prefix) {
560
783
  if (!code.startsWith('#!'))
561
784
  return prefix + code;
@@ -609,39 +832,6 @@ function bootstrapFor(entry, dist, buildId, runtimeHash, runtimeGuardChannel, ru
609
832
  // and applications remain free to delete it.
610
833
  return `/* ${BOOTSTRAP_MARKER} */ (() => { const processHost = require('node:process'); const define = (name, descriptor) => ({}).constructor.defineProperty(processHost, name, descriptor); const block = () => { try { if (!(${JSON.stringify(runtimeBlockedChannel)} in processHost)) define(${JSON.stringify(runtimeBlockedChannel)}, { configurable: false, enumerable: false, writable: false, value: true }); } catch (_ignored) {} }; try { if (${JSON.stringify(runtimeGuardChannel)} in processHost || ${JSON.stringify(runtimeBlockedChannel)} in processHost) { block(); return; } const runtimePath = require.resolve(${JSON.stringify(specifier)}); const source = require('node:fs').readFileSync(runtimePath, 'utf8'); if (require('node:crypto').createHash('sha256').update(source).digest('hex') !== ${JSON.stringify(runtimeHash)}) { block(); return; } const Module = require('node:module'); const loaded = new Module(runtimePath); loaded.filename = runtimePath; loaded.paths = Module._nodeModulePaths(require('node:path').dirname(runtimePath)); loaded._compile(source, runtimePath); loaded.loaded = true; const runtime = loaded.exports; if (!runtime || typeof runtime.install !== 'function' || typeof runtime.safeSelectProbe !== 'function') { block(); return; } define(${JSON.stringify(runtimeGuardChannel)}, { configurable: false, enumerable: false, writable: false, value: runtime }); require.cache[runtimePath] = loaded; runtime.install({ buildId: ${JSON.stringify(buildId)} }); } catch (_error) { block(); } })();\n`;
611
834
  }
612
- function insertEntryBootstrap(code, bootstrap) {
613
- const babel = loadBabel();
614
- const bootstrapAst = babel.parseSync(bootstrap, {
615
- sourceType: 'unambiguous',
616
- });
617
- if (!bootstrapAst?.program?.body?.length) {
618
- throw new Error('Telemetry bootstrap produced no statements');
619
- }
620
- const transformed = babel.transformSync(code, {
621
- parserOpts: {
622
- sourceType: 'unambiguous',
623
- plugins: ['jsx'],
624
- },
625
- plugins: [() => ({
626
- visitor: {
627
- Program: {
628
- exit(path) {
629
- path.unshiftContainer('body', bootstrapAst.program.body);
630
- },
631
- },
632
- },
633
- })],
634
- babelrc: false,
635
- configFile: false,
636
- ast: false,
637
- code: true,
638
- retainLines: true,
639
- compact: false,
640
- });
641
- if (!transformed?.code)
642
- throw new Error('Telemetry bootstrap produced no entry output');
643
- return transformed.code;
644
- }
645
835
  function esmRuntimeWrapper(runtimeHash) {
646
836
  return `import { createHash } from 'node:crypto';
647
837
  import { readFileSync } from 'node:fs';
@@ -699,10 +889,14 @@ function runtimeAssetPath() {
699
889
  export function authenticateTelemetryRuntime(controlChannel) {
700
890
  const runtimeTemplate = readFileSync(runtimeAssetPath(), 'utf8');
701
891
  if (!runtimeTemplate.includes(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER) ||
702
- !runtimeTemplate.includes(RUNTIME_INTEGRITY_PLACEHOLDER)) {
892
+ !runtimeTemplate.includes(RUNTIME_INTEGRITY_PLACEHOLDER) ||
893
+ !runtimeTemplate.includes(RUNTIME_CONTRACT_PLACEHOLDER)) {
703
894
  throw new Error('Telemetry runtime is missing its authentication placeholders');
704
895
  }
705
- const runtimeChannelSource = runtimeTemplate.replaceAll(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER, controlChannel);
896
+ // The contract's constants and vocabulary are part of what the integrity covers.
897
+ const runtimeChannelSource = runtimeTemplate
898
+ .replaceAll(RUNTIME_CONTROL_CHANNEL_PLACEHOLDER, controlChannel)
899
+ .replace(RUNTIME_CONTRACT_PLACEHOLDER, JSON.stringify(serverRuntimeContract()));
706
900
  const integrity = createHash('sha256').update(runtimeChannelSource).digest('hex');
707
901
  const source = runtimeChannelSource.replaceAll(RUNTIME_INTEGRITY_PLACEHOLDER, integrity);
708
902
  return { source, integrity, hash: createHash('sha256').update(source).digest('hex') };
@@ -716,6 +910,204 @@ function readExistingManifest(path) {
716
910
  }
717
911
  return { ...parsed, already_instrumented: true };
718
912
  }
913
+ // ─── Typed source and settings (CAPTURE-V1 rule 9b, 9c) ─────────────────────
914
+ /** The repository root settings and repository-relative paths are read against: git's top level, else `start`. */
915
+ export function repositoryRoot(start) {
916
+ try {
917
+ return realpathSync(execFileSync('git', ['rev-parse', '--show-toplevel'], {
918
+ cwd: start, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
919
+ }).trim());
920
+ }
921
+ catch {
922
+ return realpathSync(start);
923
+ }
924
+ }
925
+ const SETTINGS_LABEL = /^(?:parameter|binding):[A-Za-z_$][A-Za-z0-9_$]{0,63}$/;
926
+ function validSettingsEntry(entry) {
927
+ if (!entry || typeof entry !== 'object')
928
+ return false;
929
+ const { sourcePath, qualifiedName, label, domain } = entry;
930
+ return typeof sourcePath === 'string' && SAFE_SOURCE_PATH.test(sourcePath) && !sourcePath.startsWith('/') &&
931
+ !sourcePath.includes('\\') && sourcePath.split('/').every(part => part && part !== '.' && part !== '..') &&
932
+ typeof qualifiedName === 'string' && qualifiedName.length >= 1 && qualifiedName.length <= 256 &&
933
+ typeof label === 'string' && SETTINGS_LABEL.test(label) &&
934
+ Array.isArray(domain) && domain.length >= 1 && domain.every(literal => settingLiteralProblem(literal) === null);
935
+ }
936
+ const settingsKey = (sourcePath, qualifiedName, label) => `${sourcePath}\0${qualifiedName}\0${label}`;
937
+ /** A settings file (the approved allowlist or a proposal): its valid entries, and what was wrong with the rest. */
938
+ function readSettingsFile(repoRoot, relativePath) {
939
+ const path = join(repoRoot, relativePath);
940
+ if (!existsSync(path))
941
+ return { file: null, entries: [], problems: [] };
942
+ let parsed;
943
+ try {
944
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
945
+ }
946
+ catch (error) {
947
+ return { file: relativePath, entries: [], problems: [`${relativePath} is not JSON (${error instanceof Error ? error.message : String(error)}); no settings read`] };
948
+ }
949
+ const file = parsed;
950
+ if (file?.version !== 1 || !Array.isArray(file.settings)) {
951
+ return { file: relativePath, entries: [], problems: [`${relativePath} is not version 1 with a settings list; no settings read`] };
952
+ }
953
+ const entries = [];
954
+ const problems = [];
955
+ const seen = new Set();
956
+ file.settings.forEach((entry, index) => {
957
+ if (!validSettingsEntry(entry)) {
958
+ problems.push(`${relativePath} settings[${index}] is not a valid entry; skipped`);
959
+ return;
960
+ }
961
+ const key = settingsKey(entry.sourcePath, entry.qualifiedName, entry.label);
962
+ if (seen.has(key)) {
963
+ problems.push(`${relativePath} settings[${index}] repeats ${entry.sourcePath}#${entry.qualifiedName} ${entry.label}; the first is used`);
964
+ return;
965
+ }
966
+ seen.add(key);
967
+ entries.push({ sourcePath: entry.sourcePath, qualifiedName: entry.qualifiedName, label: entry.label, domain: [...entry.domain] });
968
+ });
969
+ return { file: relativePath, entries, problems };
970
+ }
971
+ /** Rule 9(c): the repository's reviewed settings allowlist, the only file instrumentation treats as approval (a
972
+ * proposal never is). A missing file is no settings; an unreadable file or entry is a problem the build reports,
973
+ * never a reason to fail it. */
974
+ export function readTelemetrySettings(repoRoot) {
975
+ return readSettingsFile(repoRoot, SETTINGS_FILE_PATH);
976
+ }
977
+ /** Relative imports one hop away, parsed once per build: `./types`, `./types.js` (NodeNext) and `./types/index`. */
978
+ export function typeImportResolver(repoRoot) {
979
+ const cache = new Map();
980
+ const parse = (file) => {
981
+ if (cache.has(file))
982
+ return cache.get(file);
983
+ let index = null;
984
+ try {
985
+ const plugins = file.endsWith('.tsx') ? BUNDLED_PARSER_PLUGINS['.tsx'] : BUNDLED_PARSER_PLUGINS['.ts'];
986
+ const ast = loadBabel().parseSync(readFileSync(file, 'utf8'), {
987
+ parserOpts: { sourceType: 'unambiguous', plugins }, babelrc: false, configFile: false,
988
+ });
989
+ index = buildTypeIndex(ast?.program ?? null);
990
+ }
991
+ catch {
992
+ index = null;
993
+ }
994
+ cache.set(file, index);
995
+ return index;
996
+ };
997
+ return (fromFile) => (specifier) => {
998
+ if (!specifier.startsWith('./') && !specifier.startsWith('../'))
999
+ return null;
1000
+ const base = resolve(dirname(fromFile), specifier);
1001
+ const stem = /\.[cm]?js$/.test(base) ? base.replace(/\.([cm]?)js$/, '') : base;
1002
+ const candidates = [
1003
+ ...['.ts', '.tsx', '.mts', '.cts', '.d.ts'].map(extension => `${stem}${extension}`),
1004
+ ...['index.ts', 'index.tsx', 'index.d.ts'].map(name => join(base, name)),
1005
+ ];
1006
+ const found = candidates.find(candidate => existsSync(candidate) && statSync(candidate).isFile());
1007
+ if (!found)
1008
+ return null;
1009
+ const rel = relative(repoRoot, found);
1010
+ if (rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('node_modules'))
1011
+ return null;
1012
+ return parse(found);
1013
+ };
1014
+ }
1015
+ /** The typed facts of one source file, or null when it has no types (JavaScript) or Babel cannot read them. */
1016
+ function typedFactsOf(absolutePath, code, resolverFor) {
1017
+ const extension = extname(absolutePath);
1018
+ if (!TYPED_SOURCE_EXTENSIONS.has(extension))
1019
+ return null;
1020
+ try {
1021
+ return collectTypedSiteFacts(code, BUNDLED_PARSER_PLUGINS[extension], resolverFor(absolutePath));
1022
+ }
1023
+ catch {
1024
+ return null;
1025
+ }
1026
+ }
1027
+ const labelName = (label) => label.slice(label.indexOf(':') + 1);
1028
+ /** Rule 9(c) at build time: a site records literals only when a person approved them for it AND its typed source still
1029
+ * declares them; refusals and unmatched entries are reported, never fatal. */
1030
+ class SettingsApplication {
1031
+ settings;
1032
+ approved = new Map();
1033
+ matched = new Set();
1034
+ applied = new Map();
1035
+ refused = new Map();
1036
+ constructor(settings) {
1037
+ this.settings = settings;
1038
+ for (const entry of settings.entries)
1039
+ this.approved.set(settingsKey(entry.sourcePath, entry.qualifiedName, entry.label), entry);
1040
+ }
1041
+ lookup(repoSourcePath, facts) {
1042
+ return (qualifiedName, label) => {
1043
+ const typed = facts?.get(factsKey(qualifiedName, label));
1044
+ const usable = typed && !('ambiguous' in typed) ? typed : null;
1045
+ const key = settingsKey(repoSourcePath, qualifiedName, label);
1046
+ const entry = this.approved.get(key);
1047
+ let settings = null;
1048
+ if (entry) {
1049
+ this.matched.add(key);
1050
+ const where = { source_path: repoSourcePath, qualified_name: qualifiedName, label };
1051
+ if (!usable?.domain) {
1052
+ this.refused.set(key, { ...where, why: 'its typed source no longer declares a literal domain here' });
1053
+ }
1054
+ else {
1055
+ const literals = entry.domain.filter(literal => usable.domain.some(declared => Object.is(declared, literal)));
1056
+ const why = settingRefusal([labelName(label), ...usable.typeNames], literals);
1057
+ if (why)
1058
+ this.refused.set(key, { ...where, why });
1059
+ else {
1060
+ settings = literals;
1061
+ this.applied.set(key, { ...where, literals });
1062
+ }
1063
+ }
1064
+ }
1065
+ return usable || settings ? { fields: usable?.fields ?? [], settings } : null;
1066
+ };
1067
+ }
1068
+ report(extra) {
1069
+ for (const row of extra?.applied ?? [])
1070
+ this.applied.set(settingsKey(row.source_path, row.qualified_name, row.label), row);
1071
+ for (const row of extra?.refused ?? [])
1072
+ this.refused.set(settingsKey(row.source_path, row.qualified_name, row.label), row);
1073
+ for (const key of extra?.matched ?? [])
1074
+ this.matched.add(key);
1075
+ return {
1076
+ file: this.settings.file,
1077
+ problems: [...this.settings.problems],
1078
+ applied: [...this.applied.values()],
1079
+ refused: [...this.refused.values()],
1080
+ unmatched: [...this.approved].filter(([key]) => !this.matched.has(key)).map(([, entry]) => entry),
1081
+ };
1082
+ }
1083
+ matchedKeys() {
1084
+ return [...this.matched];
1085
+ }
1086
+ }
1087
+ // ─── Source maps (rule 13b) ──────────────────────────────────────────────────
1088
+ const SOURCE_MAPPING_COMMENT = /(?:^|\n)[ \t]*\/\/[#@][ \t]+sourceMappingURL=([^\s'"`]+)[ \t]*\s*$/;
1089
+ /** The compiled file's own source map (its trailing sourceMappingURL: a data URL or a file beside it), read before the
1090
+ * transform so the instrumented file's map composes through it. `comment: null` when the file names no map; `map: null`
1091
+ * when it names one that cannot be read (the file then keeps its comment and its lines, columns uncomposed). */
1092
+ function compiledSourceMap(output, code) {
1093
+ const found = SOURCE_MAPPING_COMMENT.exec(code);
1094
+ if (!found)
1095
+ return { comment: null, url: null, map: null, path: null };
1096
+ const url = found[1];
1097
+ try {
1098
+ const inline = /^data:application\/json;(?:charset[:=][^;,]+;)?base64,(.+)$/.exec(url);
1099
+ if (inline)
1100
+ return { comment: found[0], url, map: JSON.parse(Buffer.from(inline[1], 'base64').toString('utf8')), path: null };
1101
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url))
1102
+ return { comment: found[0], url, map: null, path: null };
1103
+ const path = resolve(dirname(output), decodeURIComponent(url));
1104
+ return { comment: found[0], url, map: JSON.parse(readFileSync(path, 'utf8')), path };
1105
+ }
1106
+ catch {
1107
+ return { comment: found[0], url, map: null, path: null };
1108
+ }
1109
+ }
1110
+ // ─── Compiled-output instrumentation ────────────────────────────────────────
719
1111
  export function instrumentNodeTelemetryBuild(distDirectory, options) {
720
1112
  const requestedDist = resolve(distDirectory);
721
1113
  if (!existsSync(requestedDist) || !statSync(requestedDist).isDirectory()) {
@@ -787,8 +1179,66 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
787
1179
  normalizeLineEndings(readFileSync(output, 'utf8')),
788
1180
  ]))}`;
789
1181
  const { source: authenticatedRuntime, integrity: runtimeIntegrity, hash: authenticatedRuntimeHash, } = authenticateTelemetryRuntime(controlChannel);
1182
+ const repoRoot = repositoryRoot(process.cwd());
1183
+ const settings = new SettingsApplication(readTelemetrySettings(repoRoot));
1184
+ const resolverFor = typeImportResolver(repoRoot);
790
1185
  const pending = [];
791
1186
  const skipped = [];
1187
+ const untransformed = [];
1188
+ /** One compiled file's rewrite, computed in memory; null when the file stays as built (named in `untransformed`). */
1189
+ const transformCompiled = (output, source, sourcePath, bootstrap) => {
1190
+ const outputPath = normalizedPath(relative(dist, output));
1191
+ try {
1192
+ // Rule 9(b)/(c): the compiled file has lost its types; the TypeScript it came from has them, joined to the
1193
+ // compiled sites by function and label (sites whose names compilation changed stay shape-only).
1194
+ const typed = typedFactsOf(source, readFileSync(source, 'utf8'), resolverFor);
1195
+ let code = readFileSync(output, 'utf8');
1196
+ const ownMap = compiledSourceMap(output, code);
1197
+ if (ownMap.comment !== null && ownMap.map === null) {
1198
+ // Rule 13(b): a map that cannot be composed would leave the customer's stack traces pointing at the wrong
1199
+ // columns, so the file is left exactly as built.
1200
+ untransformed.push({ output_path: outputPath, reason: `its source map (${ownMap.url}) could not be read to compose` });
1201
+ return null;
1202
+ }
1203
+ // A readable map is consumed and re-emitted composed; its comment is removed first so the output names one map.
1204
+ if (ownMap.comment !== null)
1205
+ code = code.slice(0, code.length - ownMap.comment.length);
1206
+ const transformed = instrumentSource(code, {
1207
+ sourcePath,
1208
+ runtimeSpecifier: runtimeSpecifierForOutput(output, dist),
1209
+ moduleType: moduleTypeForOutput(output, dist),
1210
+ controlChannel,
1211
+ runtimeIntegrity,
1212
+ includedQualifiedNames: selectiveInstrumentation ? selectedSymbolsBySource.get(sourcePath) ?? new Set() : null,
1213
+ parserPlugins: ['jsx'],
1214
+ // Compiled output's coordinates are not the source's, so sites carry none (see InstrumentedSite.line).
1215
+ recordSourcePositions: false,
1216
+ moduleScopeProbes: true,
1217
+ siteFacts: settings.lookup(normalizedPath(relative(repoRoot, source)), typed),
1218
+ sourceMap: ownMap.map !== null ? { filename: output, input: ownMap.map } : null,
1219
+ bootstrap,
1220
+ });
1221
+ if (!transformed)
1222
+ return null;
1223
+ return {
1224
+ output,
1225
+ source: sourcePath,
1226
+ code: transformed.code,
1227
+ map: ownMap.map !== null && transformed.map ? { path: ownMap.path, url: ownMap.url, json: transformed.map } : null,
1228
+ probes: transformed.probes,
1229
+ sites: transformed.sites,
1230
+ typed: typed !== null,
1231
+ };
1232
+ }
1233
+ catch (error) {
1234
+ if (error instanceof SymbolSelectionError)
1235
+ throw error;
1236
+ // Rule 13(b): a file this pass cannot transform ships exactly as the build wrote it, named in the report.
1237
+ untransformed.push({ output_path: outputPath, reason: transformFailure(error) });
1238
+ return null;
1239
+ }
1240
+ };
1241
+ const sources = new Map();
792
1242
  for (const output of outputFiles) {
793
1243
  const source = sourceForOutput(output, dist, sourceRoot);
794
1244
  if (!source) {
@@ -802,27 +1252,16 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
802
1252
  if (!SAFE_SOURCE_PATH.test(sourcePath)) {
803
1253
  throw new Error(`Source path contains a telemetry-reserved character (# or |): ${sourcePath}`);
804
1254
  }
805
- const transformed = instrumentSource(readFileSync(output, 'utf8'), {
806
- sourcePath,
807
- runtimeSpecifier: runtimeSpecifierForOutput(output, dist),
808
- moduleType: moduleTypeForOutput(output, dist),
809
- controlChannel,
810
- runtimeIntegrity,
811
- includedQualifiedNames: selectiveInstrumentation ? selectedSymbolsBySource.get(sourcePath) ?? new Set() : null,
812
- parserPlugins: ['jsx'],
813
- // Compiled output has no source map here: its coordinates are not the
814
- // source's, so sites carry none (see InstrumentedSite.line).
815
- recordSourcePositions: false,
816
- moduleScopeProbes: true,
817
- });
818
- if (transformed)
819
- pending.push({ output, source: sourcePath, ...transformed });
1255
+ sources.set(output, { source, sourcePath });
1256
+ const file = transformCompiled(output, source, sourcePath);
1257
+ if (file)
1258
+ pending.push(file);
820
1259
  }
821
1260
  if (pending.length === 0) {
822
1261
  throw new Error('No compiled files had an exact source-file match; pass --source-root with the correct source directory');
823
1262
  }
824
1263
  if (!pending.some(file => file.output === entry)) {
825
- throw new Error('The entry file did not map to an exact source file; pass --source-root with the correct source directory');
1264
+ throw new Error('The entry file did not map to an exact source file, or could not be transformed; pass --source-root with the correct source directory');
826
1265
  }
827
1266
  const instrumentedSites = pending.flatMap(file => file.sites)
828
1267
  .sort((left, right) => left.site_id.localeCompare(right.site_id));
@@ -843,12 +1282,35 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
843
1282
  const runtimeGuardChannel = `${controlChannel}_runtimeGuard`;
844
1283
  const runtimeBlockedChannel = `${controlChannel}_runtimeBlocked`;
845
1284
  const bootstrap = bootstrapFor(entry, dist, buildId, authenticatedRuntimeHash, runtimeGuardChannel, runtimeBlockedChannel);
1285
+ // The entry is transformed again from its built text with the bootstrap inside the same transform, so its map is
1286
+ // generated after the bootstrap moved its lines and columns (the build identity hashes the bootstrap-free text).
1287
+ const entryIndex = pending.findIndex(file => file.output === entry);
1288
+ const entrySource = sources.get(entry);
1289
+ const bootstrapped = transformCompiled(entry, entrySource.source, entrySource.sourcePath, bootstrap);
1290
+ if (!bootstrapped || JSON.stringify(bootstrapped.sites) !== JSON.stringify(pending[entryIndex].sites)) {
1291
+ throw new Error('The entry file could not be given its telemetry bootstrap');
1292
+ }
1293
+ pending[entryIndex] = bootstrapped;
1294
+ // Every artifact is staged before anything replaces the build's own files, then committed by rename: the runtime
1295
+ // first (an ESM file imports it), the manifest last (its presence means the output is instrumented).
1296
+ const staged = [
1297
+ { path: join(runtimeDirectory, RUNTIME_FILENAME), content: authenticatedRuntime },
1298
+ { path: join(runtimeDirectory, RUNTIME_ESM_FILENAME), content: esmRuntimeWrapper(authenticatedRuntimeHash) },
1299
+ ];
846
1300
  for (const file of pending) {
847
- writeFileSync(file.output, file.output === entry ? insertEntryBootstrap(file.code, bootstrap) : file.code);
1301
+ let code = file.code;
1302
+ if (file.map) {
1303
+ const json = JSON.stringify(file.map.json);
1304
+ if (file.map.path === null) {
1305
+ code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(json).toString('base64')}\n`;
1306
+ }
1307
+ else {
1308
+ code += `\n//# sourceMappingURL=${file.map.url}\n`;
1309
+ staged.push({ path: file.map.path, content: json });
1310
+ }
1311
+ }
1312
+ staged.push({ path: file.output, content: code });
848
1313
  }
849
- mkdirSync(runtimeDirectory, { recursive: true });
850
- writeFileSync(join(runtimeDirectory, RUNTIME_FILENAME), authenticatedRuntime);
851
- writeFileSync(join(runtimeDirectory, RUNTIME_ESM_FILENAME), esmRuntimeWrapper(authenticatedRuntimeHash));
852
1314
  const result = {
853
1315
  schema_version: MANIFEST_SCHEMA_VERSION,
854
1316
  build_id: buildId,
@@ -859,14 +1321,95 @@ export function instrumentNodeTelemetryBuild(distDirectory, options) {
859
1321
  output_path: normalizedPath(relative(dist, file.output)),
860
1322
  source_path: file.source,
861
1323
  probes: file.probes,
1324
+ typed: file.typed,
862
1325
  })),
863
1326
  sites: instrumentedSites,
864
1327
  skipped_unmapped_files: skipped,
1328
+ skipped_untransformed_files: untransformed,
1329
+ settings: settings.report(),
865
1330
  already_instrumented: false,
866
1331
  };
867
- writeFileSync(manifestPath, `${JSON.stringify(result, null, 2)}\n`);
1332
+ staged.push({ path: manifestPath, content: `${JSON.stringify(result, null, 2)}\n` });
1333
+ commitStaged(staged, runtimeDirectory);
868
1334
  return result;
869
1335
  }
1336
+ /** The build output could not be put back exactly as it was; the files named are left instrumented, without a
1337
+ * manifest, so the runtime installs nothing and every probe passes values through. */
1338
+ export class PartialRestoreError extends Error {
1339
+ }
1340
+ /**
1341
+ * Rule 13(b): replace the build's files all or nothing. Every new content is written to a temporary file beside its
1342
+ * target first; only then is each target moved aside and the new file renamed into place. Any failure renames every
1343
+ * original back (newest first), removes what this created, and rethrows, so the caller can truthfully say the output
1344
+ * is unchanged. Should a restore itself fail, the runtime files stay (an instrumented ESM file imports them) and the
1345
+ * error names the files left instrumented.
1346
+ */
1347
+ function commitStaged(writes, runtimeDirectory) {
1348
+ const token = `${process.pid}-${randomBytes(4).toString('hex')}`;
1349
+ const createdRuntimeDirectory = !existsSync(runtimeDirectory);
1350
+ mkdirSync(runtimeDirectory, { recursive: true });
1351
+ const steps = [];
1352
+ const discard = () => {
1353
+ for (const step of steps)
1354
+ if (!step.committed)
1355
+ rmSync(step.temporary, { force: true });
1356
+ if (createdRuntimeDirectory)
1357
+ rmSync(runtimeDirectory, { recursive: true, force: true });
1358
+ };
1359
+ try {
1360
+ for (const write of writes) {
1361
+ const temporary = `${write.path}.haystack-stage-${token}`;
1362
+ steps.push({ path: write.path, temporary, backup: null, committed: false });
1363
+ writeFileSync(temporary, write.content);
1364
+ }
1365
+ }
1366
+ catch (error) {
1367
+ discard();
1368
+ throw error;
1369
+ }
1370
+ try {
1371
+ for (const step of steps) {
1372
+ if (existsSync(step.path)) {
1373
+ step.backup = `${step.path}.haystack-original-${token}`;
1374
+ renameSync(step.path, step.backup);
1375
+ }
1376
+ renameSync(step.temporary, step.path);
1377
+ step.committed = true;
1378
+ }
1379
+ }
1380
+ catch (error) {
1381
+ const stranded = [];
1382
+ for (const step of [...steps].reverse()) {
1383
+ try {
1384
+ if (step.backup !== null && existsSync(step.backup))
1385
+ renameSync(step.backup, step.path);
1386
+ else if (step.committed)
1387
+ rmSync(step.path, { force: true });
1388
+ }
1389
+ catch {
1390
+ stranded.push(step.path);
1391
+ }
1392
+ }
1393
+ for (const step of steps)
1394
+ rmSync(step.temporary, { force: true });
1395
+ if (stranded.length > 0) {
1396
+ throw new PartialRestoreError(`the build output could not be fully restored after ${error instanceof Error ? error.message : String(error)}; ` +
1397
+ `left instrumented and inactive: ${stranded.join(', ')}`);
1398
+ }
1399
+ if (createdRuntimeDirectory)
1400
+ rmSync(runtimeDirectory, { recursive: true, force: true });
1401
+ throw error;
1402
+ }
1403
+ for (const step of steps)
1404
+ if (step.backup !== null)
1405
+ rmSync(step.backup, { force: true });
1406
+ }
1407
+ /** A transform failure's one-line reason: Babel's parser message and position, never a code frame (it would copy
1408
+ * customer source into logs). */
1409
+ function transformFailure(error) {
1410
+ const first = String(error instanceof Error ? error.message : error).split('\n')[0];
1411
+ return first.replace(/^(?:unknown(?: file)?|\/[^:]*):\s*/, '').slice(0, 300);
1412
+ }
870
1413
  // ─── Bundled (source-level) instrumentation ─────────────────────────────────
871
1414
  // A bundler packs server code into chunks that mirror no source file, so the
872
1415
  // compiled-output pass above cannot map them. These two entry points let a
@@ -894,25 +1437,37 @@ const BUNDLED_PARSER_PLUGINS = {
894
1437
  };
895
1438
  export const BUNDLED_SOURCE_EXTENSIONS = Object.keys(BUNDLED_PARSER_PLUGINS);
896
1439
  const BUNDLED_REGISTER_FILENAME = 'register.cjs';
1440
+ const bundledSettings = new Map();
1441
+ const bundledResolvers = new Map();
897
1442
  /**
898
1443
  * Instrument one source module inside a bundler. `sourcePath` is the
899
1444
  * repository-relative path every site records, so production counts join the
900
- * change's files and line ranges exactly. A module Babel cannot parse is
901
- * reported, not thrown: the bundler's compiler may accept syntax Babel does
902
- * not, and telemetry must never be the reason a build fails. Any other error
903
- * is ours and stays loud.
1445
+ * change's files and line ranges exactly. Rule 13(b): nothing here fails a
1446
+ * build: a module Babel cannot parse or this pass cannot transform is reported
1447
+ * and ships as written, with its input map.
904
1448
  */
905
1449
  export function instrumentBundledModule(code, options) {
906
- const parserPlugins = BUNDLED_PARSER_PLUGINS[extname(options.sourcePath)];
907
- if (!parserPlugins)
908
- throw new Error(`Telemetry cannot parse ${options.sourcePath}: unsupported extension`);
909
- if (!SAFE_SOURCE_PATH.test(options.sourcePath) || options.sourcePath.startsWith('/') ||
910
- options.sourcePath.split('/').some(part => !part || part === '.' || part === '..')) {
911
- throw new Error(`Telemetry source path must be repository-relative without # or |: ${options.sourcePath}`);
912
- }
913
- let instrumented;
914
1450
  try {
915
- instrumented = instrumentSource(code, {
1451
+ const parserPlugins = BUNDLED_PARSER_PLUGINS[extname(options.sourcePath)];
1452
+ if (!parserPlugins)
1453
+ return { kind: 'unparsed', reason: 'unsupported extension' };
1454
+ if (!SAFE_SOURCE_PATH.test(options.sourcePath) || options.sourcePath.startsWith('/') ||
1455
+ options.sourcePath.split('/').some(part => !part || part === '.' || part === '..')) {
1456
+ return { kind: 'unparsed', reason: 'the source path is not repository-relative or holds # or |' };
1457
+ }
1458
+ let settingsFile = bundledSettings.get(options.sourceRoot);
1459
+ if (!settingsFile) {
1460
+ settingsFile = readTelemetrySettings(options.sourceRoot);
1461
+ bundledSettings.set(options.sourceRoot, settingsFile);
1462
+ }
1463
+ let resolverFor = bundledResolvers.get(options.sourceRoot);
1464
+ if (!resolverFor) {
1465
+ resolverFor = typeImportResolver(options.sourceRoot);
1466
+ bundledResolvers.set(options.sourceRoot, resolverFor);
1467
+ }
1468
+ const settings = new SettingsApplication(settingsFile);
1469
+ const typed = typedFactsOf(options.absolutePath, code, resolverFor);
1470
+ const instrumented = instrumentSource(code, {
916
1471
  sourcePath: options.sourcePath,
917
1472
  runtimeSpecifier: '',
918
1473
  moduleType: 'bundled',
@@ -922,18 +1477,40 @@ export function instrumentBundledModule(code, options) {
922
1477
  parserPlugins,
923
1478
  recordSourcePositions: true,
924
1479
  skipDirective: 'use client',
1480
+ skipEdgeRuntime: true,
925
1481
  moduleScopeProbes: false,
1482
+ siteFacts: settings.lookup(options.sourcePath, typed),
1483
+ sourceMap: { filename: options.absolutePath, input: options.inputSourceMap },
926
1484
  });
1485
+ if (!instrumented)
1486
+ return { kind: 'unchanged' };
1487
+ const report = settings.report();
1488
+ return {
1489
+ kind: 'instrumented',
1490
+ ...instrumented,
1491
+ typed: typed !== null,
1492
+ settings: { applied: report.applied, refused: report.refused, matched: settings.matchedKeys() },
1493
+ };
927
1494
  }
928
1495
  catch (error) {
929
- if (error?.code !== 'BABEL_PARSE_ERROR')
930
- throw error;
931
- // The parser's own one-line reason and position, e.g. "Missing semicolon.
932
- // (1:30)"; the code frame below it would copy customer source into the log.
933
- const reason = String(error.message).split('\n')[0].replace(/^unknown:\s*/, '');
934
- return { kind: 'unparsed', reason };
1496
+ return { kind: 'unparsed', reason: transformFailure(error) };
935
1497
  }
936
- return instrumented ? { kind: 'instrumented', ...instrumented } : { kind: 'unchanged' };
1498
+ }
1499
+ export function writeInertTelemetryRuntime(options) {
1500
+ const runtimeDirectory = join(resolve(options.outputDirectory), RUNTIME_DIRECTORY);
1501
+ mkdirSync(runtimeDirectory, { recursive: true });
1502
+ const report = {
1503
+ schema_version: MANIFEST_SCHEMA_VERSION,
1504
+ bundler: options.bundler,
1505
+ installed: false,
1506
+ reason: options.reason,
1507
+ skipped_unparsed_files: (options.unparsed ?? []).map(file => ({ source_path: file.sourcePath, reason: file.reason })),
1508
+ sites: [],
1509
+ };
1510
+ writeFileSync(join(runtimeDirectory, MANIFEST_FILENAME), `${JSON.stringify(report, null, 2)}\n`);
1511
+ const registerPath = join(runtimeDirectory, BUNDLED_REGISTER_FILENAME);
1512
+ writeFileSync(registerPath, `/* Haystack telemetry is not installed in this build: ${options.reason.replaceAll('*/', '* /')} */\n`);
1513
+ return { registerPath, report };
937
1514
  }
938
1515
  /**
939
1516
  * Write the runtime, its manifest and the preload that installs it into
@@ -959,6 +1536,12 @@ export function writeBundledTelemetryRuntime(options) {
959
1536
  if (sites.length > MAX_INSTRUMENTED_SITES) {
960
1537
  throw new Error(`Build contains ${sites.length} telemetry sites; maximum is ${MAX_INSTRUMENTED_SITES}`);
961
1538
  }
1539
+ const settings = new SettingsApplication(readTelemetrySettings(options.sourceRoot));
1540
+ const settingsReport = settings.report({
1541
+ applied: modules.flatMap(module => module.settings.applied),
1542
+ refused: modules.flatMap(module => module.settings.refused),
1543
+ matched: modules.flatMap(module => module.settings.matched),
1544
+ });
962
1545
  // The build identity covers exactly what was instrumented (every site's
963
1546
  // coordinates), so two builds of identical server source share one identity
964
1547
  // and any source change produces a new one.
@@ -974,10 +1557,12 @@ export function writeBundledTelemetryRuntime(options) {
974
1557
  source_root: options.sourceRoot,
975
1558
  entry: null,
976
1559
  runtime: `${RUNTIME_DIRECTORY}/${RUNTIME_FILENAME}`,
977
- instrumented_files: modules.map(module => ({ source_path: module.sourcePath, probes: module.probes })),
1560
+ instrumented_files: modules.map(module => ({ source_path: module.sourcePath, probes: module.probes, typed: module.typed })),
978
1561
  skipped_unparsed_files: [...new Map(options.unparsed.map(file => [file.sourcePath, file])).values()]
979
1562
  .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath))
980
1563
  .map(file => ({ source_path: file.sourcePath, reason: file.reason })),
1564
+ source_identity: options.sourceIdentity,
1565
+ settings: settingsReport,
981
1566
  sites,
982
1567
  };
983
1568
  writeFileSync(join(runtimeDirectory, MANIFEST_FILENAME), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -989,18 +1574,49 @@ export function writeBundledTelemetryRuntime(options) {
989
1574
  writeFileSync(registerPath, bootstrapFor(registerPath, outputDirectory, buildId, runtime.hash, `${options.controlChannel}_runtimeGuard`, `${options.controlChannel}_runtimeBlocked`));
990
1575
  return { registerPath, manifest };
991
1576
  }
1577
+ /** The settings and typed-source lines a build prints (rule 8e: init's status repeats them). */
1578
+ export function settingsSummary(report, typedFiles, totalFiles) {
1579
+ const lines = [`Haystack telemetry: ${typedFiles} of ${totalFiles} instrumented file(s) read with their types; ` +
1580
+ `${totalFiles - typedFiles} shape-only (no declared fields or setting literals).`];
1581
+ if (report.file === null)
1582
+ lines.push(`Haystack telemetry: no ${SETTINGS_FILE_PATH}; no setting literals are recorded (propose one with \`haystack telemetry settings --propose\`).`);
1583
+ else
1584
+ lines.push(`Haystack telemetry: ${report.applied.length} approved setting(s) recorded as literals; ${report.refused.length} refused; ${report.unmatched.length} approved entr${report.unmatched.length === 1 ? 'y matches' : 'ies match'} no site.`);
1585
+ for (const problem of report.problems)
1586
+ lines.push(`Haystack telemetry: ${problem}`);
1587
+ for (const refused of report.refused)
1588
+ lines.push(` refused ${refused.source_path}#${refused.qualified_name} ${refused.label}: ${refused.why}`);
1589
+ return lines;
1590
+ }
992
1591
  export async function telemetryInstrumentCommand(distDirectory, options) {
993
- let includeSymbols = options.includeSymbols;
994
- if (options.includeSymbolsFile) {
995
- const parsed = JSON.parse(readFileSync(resolve(options.includeSymbolsFile), 'utf8'));
996
- const rows = Array.isArray(parsed) ? parsed : parsed?.symbols;
997
- if (!Array.isArray(rows)) {
998
- throw new Error('--include-symbols JSON must be an array or an object with a symbols array');
1592
+ // Rule 13(b): this runs after the customer's build; whatever goes wrong here, the build's output stays as it was
1593
+ // and the command exits 0, saying why telemetry was not added.
1594
+ let result;
1595
+ try {
1596
+ let includeSymbols = options.includeSymbols;
1597
+ if (options.includeSymbolsFile) {
1598
+ const parsed = JSON.parse(readFileSync(resolve(options.includeSymbolsFile), 'utf8'));
1599
+ const rows = Array.isArray(parsed) ? parsed : parsed?.symbols;
1600
+ if (!Array.isArray(rows)) {
1601
+ throw new Error('--include-symbols JSON must be an array or an object with a symbols array');
1602
+ }
1603
+ includeSymbols = rows;
999
1604
  }
1000
- includeSymbols = rows;
1605
+ await preloadBabel();
1606
+ result = instrumentNodeTelemetryBuild(distDirectory, { ...options, includeSymbols });
1607
+ }
1608
+ catch (error) {
1609
+ const reason = error instanceof Error ? error.message : String(error);
1610
+ // Only a failed restore leaves anything changed, and then it says which files (instrumented, inactive).
1611
+ const unchanged = !(error instanceof PartialRestoreError);
1612
+ if (options.json)
1613
+ console.log(JSON.stringify({ instrumented: false, outputUnchanged: unchanged, reason }, null, 2));
1614
+ else
1615
+ console.error(chalk.yellow(unchanged
1616
+ ? `Haystack telemetry was not added; the build output is unchanged: ${reason}`
1617
+ : `Haystack telemetry was not added: ${reason}`));
1618
+ return;
1001
1619
  }
1002
- await preloadBabel();
1003
- const result = instrumentNodeTelemetryBuild(distDirectory, { ...options, includeSymbols });
1004
1620
  if (options.json) {
1005
1621
  console.log(JSON.stringify(result, null, 2));
1006
1622
  return;
@@ -1011,8 +1627,174 @@ export async function telemetryInstrumentCommand(distDirectory, options) {
1011
1627
  }
1012
1628
  const probes = result.instrumented_files.reduce((total, file) => total + file.probes.branches + file.probes.parameters + file.probes.bindings + file.probes.returns + file.probes.throws, 0);
1013
1629
  console.log(chalk.green(`Instrumented ${result.instrumented_files.length} file(s) with ${probes} telemetry probes.`));
1630
+ for (const line of settingsSummary(result.settings, result.instrumented_files.filter(file => file.typed).length, result.instrumented_files.length))
1631
+ console.log(chalk.dim(line));
1014
1632
  console.log(chalk.dim('No source imports were added. Set HAYSTACK_TELEMETRY=1, HAYSTACK_TELEMETRY_ENDPOINT, and HAYSTACK_TELEMETRY_TOKEN at runtime.'));
1015
1633
  if (result.skipped_unmapped_files.length > 0) {
1016
1634
  console.log(chalk.yellow(`Skipped ${result.skipped_unmapped_files.length} generated/unmapped file(s); see --json for exact paths.`));
1017
1635
  }
1636
+ if (result.skipped_untransformed_files.length > 0) {
1637
+ console.log(chalk.yellow(`Shipped ${result.skipped_untransformed_files.length} file(s) uninstrumented because they could not be transformed:`));
1638
+ for (const file of result.skipped_untransformed_files)
1639
+ console.log(chalk.yellow(` ${file.output_path}: ${file.reason}`));
1640
+ }
1641
+ }
1642
+ // ─── `haystack telemetry settings --propose` (rule 9c) ──────────────────────
1643
+ const PROPOSE_SKIPPED_PATH = /(?:^|\/)(?:node_modules|__tests__|__mocks__)\/|\.(?:test|spec)\.[cm]?tsx?$|\.d\.ts$/;
1644
+ function settingsSourceFiles(repoRoot) {
1645
+ let files;
1646
+ try {
1647
+ files = execFileSync('git', ['ls-files', '-z'], {
1648
+ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024,
1649
+ }).split('\0').filter(Boolean);
1650
+ }
1651
+ catch {
1652
+ files = fg.sync('**/*.{ts,tsx,mts,cts}', { cwd: repoRoot, onlyFiles: true, ignore: ['**/node_modules/**'] });
1653
+ }
1654
+ return files
1655
+ .filter(file => TYPED_SOURCE_EXTENSIONS.has(extname(file)) && !PROPOSE_SKIPPED_PATH.test(file) && SAFE_SOURCE_PATH.test(file))
1656
+ .sort();
1657
+ }
1658
+ /**
1659
+ * Rule 9(c): propose settings. Every parameter and binding of the repository's typed source whose type is a closed set
1660
+ * of literals (a union, a boolean, an enum, an `as const` array or object) is a candidate, refused when its name or a
1661
+ * literal looks like identity or a secret. The proposal (SETTINGS_PROPOSAL_PATH) is the approved entries, unchanged,
1662
+ * plus the new candidates; it approves nothing: only `haystack telemetry settings --approve`, run by a person, makes it
1663
+ * the allowlist instrumentation reads.
1664
+ */
1665
+ export function proposeTelemetrySettings(repoRoot) {
1666
+ const existing = readTelemetrySettings(repoRoot);
1667
+ const resolverFor = typeImportResolver(repoRoot);
1668
+ const babel = loadBabel();
1669
+ const known = new Set(existing.entries.map(entry => settingsKey(entry.sourcePath, entry.qualifiedName, entry.label)));
1670
+ const added = [];
1671
+ const refused = [];
1672
+ let typedSource = false;
1673
+ for (const sourcePath of settingsSourceFiles(repoRoot)) {
1674
+ const absolutePath = join(repoRoot, sourcePath);
1675
+ let code;
1676
+ try {
1677
+ code = readFileSync(absolutePath, 'utf8');
1678
+ const program = babel.parseSync(code, {
1679
+ parserOpts: { sourceType: 'unambiguous', plugins: BUNDLED_PARSER_PLUGINS[extname(sourcePath)] },
1680
+ babelrc: false, configFile: false,
1681
+ })?.program;
1682
+ // Client and edge modules are never instrumented, so their parameters are never sites.
1683
+ if (!program || (program.directives ?? []).some((directive) => directive.value?.value === 'use client') ||
1684
+ declaresEdgeRuntime(program))
1685
+ continue;
1686
+ }
1687
+ catch {
1688
+ continue;
1689
+ }
1690
+ const facts = typedFactsOf(absolutePath, code, resolverFor);
1691
+ if (!facts)
1692
+ continue;
1693
+ typedSource = true;
1694
+ for (const [key, value] of facts) {
1695
+ const [qualifiedName, label] = key.split('\0');
1696
+ if ('ambiguous' in value || !value.domain)
1697
+ continue;
1698
+ const where = { sourcePath, qualifiedName, label };
1699
+ if (known.has(settingsKey(sourcePath, qualifiedName, label)))
1700
+ continue;
1701
+ const why = settingRefusal([labelName(label), ...value.typeNames], value.domain);
1702
+ if (why) {
1703
+ refused.push({ ...where, why });
1704
+ continue;
1705
+ }
1706
+ added.push({ ...where, domain: value.domain });
1707
+ }
1708
+ }
1709
+ return {
1710
+ path: SETTINGS_PROPOSAL_PATH,
1711
+ approvedPath: SETTINGS_FILE_PATH,
1712
+ file: { version: 1, settings: [...existing.entries, ...added].sort(settingsOrder) },
1713
+ added: added.sort(settingsOrder),
1714
+ refused,
1715
+ typedSource,
1716
+ };
1717
+ }
1718
+ const settingsOrder = (left, right) => left.sourcePath.localeCompare(right.sourcePath) || left.qualifiedName.localeCompare(right.qualifiedName) ||
1719
+ left.label.localeCompare(right.label);
1720
+ /** What promoting the proposal would change in the approved allowlist, entry by entry. */
1721
+ export function settingsApprovalDiff(repoRoot) {
1722
+ const approved = readTelemetrySettings(repoRoot);
1723
+ const proposal = readSettingsFile(repoRoot, SETTINGS_PROPOSAL_PATH);
1724
+ const keyOf = (entry) => settingsKey(entry.sourcePath, entry.qualifiedName, entry.label);
1725
+ const before = new Map(approved.entries.map(entry => [keyOf(entry), entry]));
1726
+ const after = new Map(proposal.entries.map(entry => [keyOf(entry), entry]));
1727
+ const refused = proposal.entries.flatMap(entry => {
1728
+ const why = settingRefusal([labelName(entry.label)], entry.domain);
1729
+ return why ? [{ entry, why }] : [];
1730
+ });
1731
+ return {
1732
+ proposal,
1733
+ added: [...after].filter(([key]) => !before.has(key)).map(([, entry]) => entry),
1734
+ removed: [...before].filter(([key]) => !after.has(key)).map(([, entry]) => entry),
1735
+ changed: [...after].flatMap(([key, entry]) => {
1736
+ const from = before.get(key);
1737
+ return from && JSON.stringify(from.domain) !== JSON.stringify(entry.domain) ? [{ from, to: entry }] : [];
1738
+ }),
1739
+ refused,
1740
+ };
1741
+ }
1742
+ const describeEntry = (entry) => `${entry.sourcePath}#${entry.qualifiedName} ${entry.label}: ${entry.domain.map(literal => JSON.stringify(literal)).join(' | ')}`;
1743
+ export async function telemetrySettingsCommand(options) {
1744
+ if (Boolean(options.propose) === Boolean(options.approve)) {
1745
+ throw new Error('Pass --propose (write candidates to review) or --approve (promote the reviewed proposal)');
1746
+ }
1747
+ await preloadBabel();
1748
+ const repoRoot = repositoryRoot(process.cwd());
1749
+ if (options.approve) {
1750
+ const diff = settingsApprovalDiff(repoRoot);
1751
+ if (diff.proposal.file === null)
1752
+ throw new Error(`There is no ${SETTINGS_PROPOSAL_PATH}; run --propose first`);
1753
+ if (diff.proposal.problems.length > 0 || diff.refused.length > 0) {
1754
+ throw new Error(`${SETTINGS_PROPOSAL_PATH} cannot be approved: ${[...diff.proposal.problems,
1755
+ ...diff.refused.map(row => `${describeEntry(row.entry)} (${row.why})`)].join('; ')}`);
1756
+ }
1757
+ if (!options.dryRun) {
1758
+ mkdirSync(join(repoRoot, dirname(SETTINGS_FILE_PATH)), { recursive: true });
1759
+ const approved = { version: 1, settings: [...diff.proposal.entries].sort(settingsOrder) };
1760
+ writeFileSync(join(repoRoot, SETTINGS_FILE_PATH), `${JSON.stringify(approved, null, 2)}\n`);
1761
+ rmSync(join(repoRoot, SETTINGS_PROPOSAL_PATH), { force: true });
1762
+ }
1763
+ if (options.json) {
1764
+ console.log(JSON.stringify({ path: SETTINGS_FILE_PATH, approved: !options.dryRun, added: diff.added, removed: diff.removed,
1765
+ changed: diff.changed }, null, 2));
1766
+ return;
1767
+ }
1768
+ console.log(`${options.dryRun ? 'Approving would change' : 'Approved'} ${SETTINGS_FILE_PATH}: ` +
1769
+ `${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed.`);
1770
+ for (const entry of diff.added)
1771
+ console.log(chalk.green(` + ${describeEntry(entry)}`));
1772
+ for (const entry of diff.removed)
1773
+ console.log(chalk.red(` - ${describeEntry(entry)}`));
1774
+ for (const row of diff.changed)
1775
+ console.log(chalk.yellow(` ~ ${describeEntry(row.from)} -> ${row.to.domain.map(literal => JSON.stringify(literal)).join(' | ')}`));
1776
+ if (!options.dryRun)
1777
+ console.log('Commit it: telemetry records a value as itself only at an entry in this file.');
1778
+ return;
1779
+ }
1780
+ const proposal = proposeTelemetrySettings(repoRoot);
1781
+ if (!options.dryRun) {
1782
+ mkdirSync(join(repoRoot, dirname(SETTINGS_PROPOSAL_PATH)), { recursive: true });
1783
+ writeFileSync(join(repoRoot, SETTINGS_PROPOSAL_PATH), `${JSON.stringify(proposal.file, null, 2)}\n`);
1784
+ }
1785
+ if (options.json) {
1786
+ console.log(JSON.stringify(proposal, null, 2));
1787
+ return;
1788
+ }
1789
+ if (!proposal.typedSource) {
1790
+ console.log(chalk.yellow('No typed source (TypeScript) was found: server telemetry records shapes and sizes only, no setting literals.'));
1791
+ }
1792
+ console.log(`${options.dryRun ? 'Would propose' : 'Proposed'} ${proposal.added.length} new setting candidate(s) in ${SETTINGS_PROPOSAL_PATH}; ` +
1793
+ `${proposal.refused.length} refused. Nothing is approved yet.`);
1794
+ for (const entry of proposal.added)
1795
+ console.log(chalk.dim(` ${describeEntry(entry)}`));
1796
+ for (const entry of proposal.refused)
1797
+ console.log(chalk.dim(` refused ${entry.sourcePath}#${entry.qualifiedName} ${entry.label}: ${entry.why}`));
1798
+ console.log(`Review it (remove any entry or literal that should stay shape-only), then run \`haystack telemetry settings --approve\`,`
1799
+ + ` which shows the change to ${SETTINGS_FILE_PATH} and makes it the allowlist.`);
1018
1800
  }