@haystackeditor/cli 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
- package/dist/assets/capture/release.json +4 -0
- package/dist/assets/telemetry/runtime.cjs +829 -1254
- package/dist/capture/adapters/client-routes.js +383 -0
- package/dist/capture/adapters/django.js +127 -0
- package/dist/capture/adapters/files.js +77 -0
- package/dist/capture/adapters/index.js +64 -0
- package/dist/capture/adapters/jsx-edit.js +81 -0
- package/dist/capture/adapters/next.js +327 -0
- package/dist/capture/adapters/nuxt.js +192 -0
- package/dist/capture/adapters/rails.js +171 -0
- package/dist/capture/adapters/react-router.js +432 -0
- package/dist/capture/adapters/sveltekit.js +102 -0
- package/dist/capture/adapters/types.js +4 -0
- package/dist/capture/adapters/vite.js +121 -0
- package/dist/capture/app-config.js +106 -0
- package/dist/capture/consent.js +127 -0
- package/dist/capture/csp.js +331 -0
- package/dist/capture/html.js +74 -0
- package/dist/capture/js-ast.js +400 -0
- package/dist/capture/manifest.js +95 -0
- package/dist/capture/project.js +177 -0
- package/dist/capture/route-pattern.js +119 -0
- package/dist/capture/script-release.js +47 -0
- package/dist/capture/tag.js +74 -0
- package/dist/capture-step.js +53 -0
- package/dist/commands/capture-brief.js +92 -0
- package/dist/commands/capture-contract.js +46 -0
- package/dist/commands/capture-manifest.js +78 -0
- package/dist/commands/init-capture.js +409 -0
- package/dist/commands/init-telemetry.js +1011 -0
- package/dist/commands/init.js +68 -5
- package/dist/commands/server-telemetry-contract.d.ts +66 -0
- package/dist/commands/server-telemetry-contract.js +127 -0
- package/dist/commands/telemetry-token.js +238 -0
- package/dist/commands/telemetry.d.ts +161 -8
- package/dist/commands/telemetry.js +940 -158
- package/dist/commands/verify-onboarding.js +5 -1
- package/dist/commands/verify.js +54 -7
- package/dist/index.js +83 -6
- package/dist/schema.js +2 -2
- package/dist/telemetry/next-loader.cjs +66 -9
- package/dist/telemetry/next.d.ts +11 -3
- package/dist/telemetry/next.js +95 -15
- package/dist/telemetry/typed-source.d.ts +47 -0
- package/dist/telemetry/typed-source.js +379 -0
- package/package.json +4 -2
- package/schemas/init.v1.json +63 -4
- package/schemas/pre-verify.v1.json +60 -3
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
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,
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
345
|
-
siteProbeNames.
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
483
|
+
...bootstrapStatements,
|
|
484
|
+
...control.imports,
|
|
407
485
|
passthroughHelper(t, helperName),
|
|
408
486
|
sitePassthroughHelper(t, siteHelperName),
|
|
409
|
-
...[...siteProbeNames.entries()].map(([siteKey,
|
|
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
|
|
452
|
-
const
|
|
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:${
|
|
484
|
-
const
|
|
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 (
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
901
|
-
*
|
|
902
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
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
|
}
|