@haystackeditor/cli 0.24.0 → 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 +75 -9
- 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
|
@@ -28,6 +28,7 @@ const STATES = ['ready', 'onboarding', 'blocked', 'incomplete', 'cancelled', 'no
|
|
|
28
28
|
const BLOCK_KINDS = ['not-enabled', 'no-web-app', 'unsupported', 'question', 'outside-credential',
|
|
29
29
|
'outside-dependency', 'evidence-too-large', 'budget', 'proof-failed'];
|
|
30
30
|
const STAGE_STATUSES = ['pending', 'running', 'ok', 'reused', 'failed', 'blocked'];
|
|
31
|
+
const SERVICE_ENGINES = ['postgres', 'mysql', 'redis', 'mongodb', 'convex'];
|
|
31
32
|
const STAGE_WORDS = {
|
|
32
33
|
inventory: 'reading the repository', plan: 'planning how to run the app', runtime: 'building its runtime',
|
|
33
34
|
boot: 'starting the app', data: 'preparing its data', accounts: 'creating test accounts', workflow: 'proving a workflow',
|
|
@@ -99,6 +100,9 @@ export function parseOnboardingStatus(value, expected) {
|
|
|
99
100
|
invalid('its stand-ins');
|
|
100
101
|
if (!(value.worldMemoryMib === null || (typeof value.worldMemoryMib === 'number' && Number.isInteger(value.worldMemoryMib))))
|
|
101
102
|
invalid('its world memory');
|
|
103
|
+
if (!Array.isArray(value.services) || !value.services.every(service => isRecord(service) && typeof service.id === 'string'
|
|
104
|
+
&& SERVICE_ENGINES.includes(service.engine)))
|
|
105
|
+
invalid('its services');
|
|
102
106
|
if (value.block !== null)
|
|
103
107
|
parseOnboardingBlock(value.block);
|
|
104
108
|
const checkpoint = value.checkpoint;
|
|
@@ -336,7 +340,7 @@ function repositoryOf(value) {
|
|
|
336
340
|
const origin = resolveOriginRepository(gitRoot);
|
|
337
341
|
return `${origin.owner}/${origin.repository}`;
|
|
338
342
|
}
|
|
339
|
-
async function readFacts(repository, token) {
|
|
343
|
+
export async function readFacts(repository, token) {
|
|
340
344
|
const response = await gatewayFetch(`${FACTS_PATH}?repository=${encodeURIComponent(repository)}`, token);
|
|
341
345
|
if (!response.ok)
|
|
342
346
|
throw await classifyHttpError(response, `Haystack API ${FACTS_PATH}`);
|
package/dist/commands/verify.js
CHANGED
|
@@ -32,6 +32,7 @@ import { GATEWAY_TIMEOUT_MS, gatewayFetch } from './case-batch.js';
|
|
|
32
32
|
import { CRAWL_BRIEF_MAX_FUNCTIONS, CRAWL_BRIEF_MAX_GROUPS, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, CRAWL_BUDGET_MAX_MS, CRAWL_BUDGET_MIN_MS, CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, CRAWL_MAX_IDEAS, CRAWL_MAX_STAND_IN_LINES, CRAWL_MAX_STAND_IN_ROWS, CRAWL_MAX_TEXT_CHARS, CRAWL_WAIT_MAX_MS, CRAWL_POOLS, } from './crawl-contract.js';
|
|
33
33
|
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
34
34
|
import { formatOnboarding, onboardingExitCode, readOnboardingStatus, reportOnboardingState, standInGapNotes, waitForOnboarding } from './verify-onboarding.js';
|
|
35
|
+
import { formatCapture, readPreVerifyCapture } from './capture-brief.js';
|
|
35
36
|
const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
36
37
|
/** A held read (waitAfter): the service's hold, then the time any read gets. */
|
|
37
38
|
const HELD_READ_TIMEOUT_MS = CRAWL_WAIT_MAX_MS + GATEWAY_TIMEOUT_MS;
|
|
@@ -256,13 +257,23 @@ function checkBrief(value) {
|
|
|
256
257
|
// Amendment 19: each production row names a function the brief holds.
|
|
257
258
|
const production = value.production;
|
|
258
259
|
const line = (item) => isCount(item) && item >= 1;
|
|
260
|
+
const shares = (item) => records(item, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, row => text(row.category)
|
|
261
|
+
&& typeof row.share === 'number' && Number.isFinite(row.share) && row.share >= 0);
|
|
262
|
+
// CAPTURE-V1 rule 9(d): sampler-5 estimates. A worker from before them sends none; that reads as none.
|
|
263
|
+
const estimates = (fn) => fn.estimates === undefined
|
|
264
|
+
|| (isCount(fn.estimatesOmitted) && records(fn.estimates, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, estimate => line(estimate.line)
|
|
265
|
+
&& (estimate.kind === 'branch' || estimate.kind === 'setting' || estimate.kind === 'value') && text(estimate.what)
|
|
266
|
+
&& isCount(estimate.calls) && isCount(estimate.classified) && typeof estimate.partial === 'boolean' && isCount(estimate.partialCalls)
|
|
267
|
+
&& shares(estimate.shares) && records(estimate.fields, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, field => text(field.field)
|
|
268
|
+
&& isCount(field.objectCalls) && shares(field.shares))));
|
|
259
269
|
if (production !== undefined && (!isRecord(production) || !isCount(production.windowDays) || !isCount(production.payloads)
|
|
260
270
|
|| !text(production.newestAt, true) || typeof production.partial !== 'boolean' || !records(production.functions, CRAWL_BRIEF_MAX_FUNCTIONS, fn => isCount(fn.index)
|
|
261
271
|
&& fn.index < value.functions.length && isCount(fn.branchesOmitted) && isCount(fn.valuesOmitted)
|
|
262
272
|
&& records(fn.branches, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, branch => line(branch.line) && isCount(branch.whenTrue)
|
|
263
273
|
&& isCount(branch.whenFalse) && typeof branch.capped === 'boolean')
|
|
264
274
|
&& records(fn.values, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, observed => line(observed.line) && text(observed.what)
|
|
265
|
-
&& records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count)))
|
|
275
|
+
&& records(observed.shapes, CRAWL_BRIEF_MAX_PRODUCTION_ROWS, shape => text(shape.shape) && isCount(shape.count)))
|
|
276
|
+
&& estimates(fn))))
|
|
266
277
|
invalid('its brief');
|
|
267
278
|
}
|
|
268
279
|
function checkManifest(value, view) {
|
|
@@ -742,7 +753,7 @@ async function submitCapture(capture, token, found) {
|
|
|
742
753
|
}
|
|
743
754
|
if (crawl.status === 'unavailable') {
|
|
744
755
|
return { runId: null, onboarding: null,
|
|
745
|
-
line: `${before}, and ${found === null ? '
|
|
756
|
+
line: `${before}, and ${found === null ? 'one' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
|
|
746
757
|
}
|
|
747
758
|
if (crawl.status === 'onboarding') {
|
|
748
759
|
return { runId: null, onboarding: 'onboarding', line: `${before}. The app is not onboarded for this base yet: `
|
|
@@ -833,6 +844,32 @@ async function waitForBrief(first, identity, token) {
|
|
|
833
844
|
process.removeListener('SIGINT', interrupted);
|
|
834
845
|
}
|
|
835
846
|
}
|
|
847
|
+
/** A share as the brief says it: rounded, a share too small to round still visible, and an estimate past 1 (sampling
|
|
848
|
+
* error) said as about all of them; the data keeps the estimate itself. */
|
|
849
|
+
function percent(share) {
|
|
850
|
+
return share > 0 && share < 0.005 ? '<1%' : `~${Math.round(Math.min(share, 1) * 100)}%`;
|
|
851
|
+
}
|
|
852
|
+
const SHOWN_SHARES = 8;
|
|
853
|
+
/** CAPTURE-V1 rule 9(d): "undefined ~12% (4,180 classified of 41,200)" per site, of observed calls, never users. */
|
|
854
|
+
function describeEstimate(estimate) {
|
|
855
|
+
const number = (value) => value.toLocaleString('en-US');
|
|
856
|
+
const sample = `${number(estimate.classified)} classified of ${number(estimate.calls)}`
|
|
857
|
+
+ (estimate.partial ? '; partial: classification was skipped in every window, so these are shares of the classified calls'
|
|
858
|
+
: estimate.partialCalls > 0 ? `; ${number(estimate.partialCalls)} calls in windows that skipped classification not included` : '');
|
|
859
|
+
const listed = (shares) => shares.slice(0, SHOWN_SHARES)
|
|
860
|
+
.map(row => `${safe(row.category)} ${percent(row.share)}`).join(', ')
|
|
861
|
+
+ (shares.length > SHOWN_SHARES ? `, ${shares.length - SHOWN_SHARES} more (in --json)` : '');
|
|
862
|
+
if (estimate.kind === 'branch') {
|
|
863
|
+
const whenTrue = estimate.shares.find(row => row.category === 'true')?.share ?? 0;
|
|
864
|
+
return [` in production: line ${estimate.line} was true in ${percent(whenTrue)} of observed calls (${sample})`];
|
|
865
|
+
}
|
|
866
|
+
const lines = [` in production: line ${estimate.line} ${safe(estimate.what)}${estimate.kind === 'setting' ? ' (an approved setting)' : ''}`
|
|
867
|
+
+ ` in observed calls: ${listed(estimate.shares)} (${sample})`];
|
|
868
|
+
for (const field of estimate.fields) {
|
|
869
|
+
lines.push(` .${safe(field.field)} where it was an object (~${number(field.objectCalls)} calls): ${listed(field.shares)}`);
|
|
870
|
+
}
|
|
871
|
+
return lines;
|
|
872
|
+
}
|
|
836
873
|
/** The brief as a coding agent reads it: what the change touches, then Haystack's own ideas, then how to hand back intent and
|
|
837
874
|
* ideas. Every function is in --json; the text shows the changed ones and those one step away, and says how many more. */
|
|
838
875
|
export function formatBrief(brief) {
|
|
@@ -860,12 +897,18 @@ export function formatBrief(brief) {
|
|
|
860
897
|
+ observed.shapes.map(shape => `${safe(shape.shape)} (${shape.count})`).join(', '));
|
|
861
898
|
if (row.valuesOmitted)
|
|
862
899
|
lines.push(` and ${plural(row.valuesOmitted, 'more observed value')} (in --json)`);
|
|
900
|
+
for (const estimate of row.estimates ?? [])
|
|
901
|
+
lines.push(...describeEstimate(estimate));
|
|
902
|
+
if (row.estimatesOmitted)
|
|
903
|
+
lines.push(` and ${plural(row.estimatesOmitted, 'more estimated site')} (in --json)`);
|
|
863
904
|
});
|
|
864
905
|
if (brief.production) {
|
|
865
906
|
const shownSeen = brief.production.functions.filter(row => brief.functions[row.index].distance <= 1).length;
|
|
866
907
|
lines.push(` Production telemetry: the last ${brief.production.windowDays} days, newest ${safe(brief.production.newestAt)};`
|
|
867
908
|
+ ` ${plural(brief.production.functions.length, 'function')} seen running (${shownSeen} above). Counts marked + are lower bounds`
|
|
868
|
-
+ `${brief.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`
|
|
909
|
+
+ `${brief.production.partial ? '; the read stopped at its budget, so every count is a lower bound' : ''}.`
|
|
910
|
+
+ `${brief.production.functions.some(row => (row.estimates ?? []).length > 0)
|
|
911
|
+
? ' Shares are estimates of the observed calls at each site (heavier users make more calls), never of users.' : ''}`);
|
|
869
912
|
}
|
|
870
913
|
else
|
|
871
914
|
lines.push(' No production telemetry for this repository: set it up to see how this code runs for real users.');
|
|
@@ -887,7 +930,7 @@ export function formatBrief(brief) {
|
|
|
887
930
|
lines.push(` ${safe(change.changeKey)}: ${safe(change.reason)}`);
|
|
888
931
|
}
|
|
889
932
|
}
|
|
890
|
-
lines.push('', 'Next: pick the ideas worth trying, add your own, and say what the
|
|
933
|
+
lines.push('', 'Next: pick the ideas worth trying, add your own, and say what you were asked to do (the task in the user\'s words, not what your code does):', ' haystack verify --intent "<what you were asked to do>" --idea "<something to try>" [--idea ...]', 'Your ideas are explored first; the ideas above are explored in every crawl too.');
|
|
891
934
|
return lines.join('\n');
|
|
892
935
|
}
|
|
893
936
|
/** `haystack pre-verify` (CRAWL-V1 amendment 18): the brief of the current change, before any crawl. It captures the checkout as
|
|
@@ -901,10 +944,10 @@ export async function preVerifyCommand(options) {
|
|
|
901
944
|
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, 'prepare', repositoryOverride(options.repo));
|
|
902
945
|
const request = capture.derivation.request;
|
|
903
946
|
const change = checkedChange(request.baseCommit, request.workCommit);
|
|
904
|
-
const finish = (onboarding, view) => {
|
|
947
|
+
const finish = (onboarding, view, captured = null) => {
|
|
905
948
|
if (options.json)
|
|
906
949
|
process.stdout.write(`${JSON.stringify(withSchema('pre-verify', { change, onboarding,
|
|
907
|
-
run: view === null ? null : { runId: view.runId, status: view.status }, brief: view === null ? null : briefOf(view) }), null, 2)}\n`);
|
|
950
|
+
run: view === null ? null : { runId: view.runId, status: view.status }, brief: view === null ? null : briefOf(view), capture: captured }), null, 2)}\n`);
|
|
908
951
|
};
|
|
909
952
|
if (!request.crawl) {
|
|
910
953
|
note(`Nothing to check: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
|
|
@@ -954,8 +997,10 @@ export async function preVerifyCommand(options) {
|
|
|
954
997
|
if (options.wait !== false && briefOf(view) === null && !TERMINAL.has(reportedStatus(view)))
|
|
955
998
|
view = await waitForBrief(view, identity, auth.token);
|
|
956
999
|
const brief = briefOf(view);
|
|
1000
|
+
// CAPTURE-V1 rule 7b: the routes the change touches, with their share of captured sessions; null without capture set up.
|
|
1001
|
+
const captured = await readPreVerifyCapture(findGitRoot(), identity.repository, auth.token, [...new Set([...(brief?.functions.map(fn => fn.file) ?? []), ...change.files])], new Date());
|
|
957
1002
|
if (options.json)
|
|
958
|
-
finish(onboarding, view);
|
|
1003
|
+
finish(onboarding, view, captured);
|
|
959
1004
|
else if (brief !== null)
|
|
960
1005
|
console.log(formatBrief(brief));
|
|
961
1006
|
else if (!TERMINAL.has(reportedStatus(view)))
|
|
@@ -968,6 +1013,8 @@ export async function preVerifyCommand(options) {
|
|
|
968
1013
|
+ (view.status === 'prepared' ? 'Run `haystack verify` to crawl the change; its results carry the brief.'
|
|
969
1014
|
: view.status === 'completed' ? 'It was checked before briefs existed and is not run again: change anything in the checkout and run `haystack pre-verify` again.'
|
|
970
1015
|
: 'Run `haystack pre-verify` again to prepare it again.'));
|
|
1016
|
+
if (!options.json && captured !== null)
|
|
1017
|
+
console.log(`\n${formatCapture(captured)}`);
|
|
971
1018
|
if (brief === null && TERMINAL.has(reportedStatus(view)))
|
|
972
1019
|
process.exitCode = 2;
|
|
973
1020
|
}
|
package/dist/index.js
CHANGED
|
@@ -31,6 +31,7 @@ import { lazy } from './lazy.js';
|
|
|
31
31
|
// the startup path of every invocation, including --version and --help.
|
|
32
32
|
const statusCommand = lazy(() => import('./commands/status.js'), 'statusCommand');
|
|
33
33
|
const initCommand = lazy(() => import('./commands/init.js'), 'initCommand');
|
|
34
|
+
const captureManifestCommand = lazy(() => import('./commands/capture-manifest.js'), 'captureManifestCommand');
|
|
34
35
|
const authListCommand = lazy(() => import('./commands/login.js'), 'authListCommand');
|
|
35
36
|
const authUseCommand = lazy(() => import('./commands/login.js'), 'authUseCommand');
|
|
36
37
|
const loginCommand = lazy(() => import('./commands/login.js'), 'loginCommand');
|
|
@@ -72,6 +73,8 @@ const prReadCommand = lazy(() => import('./commands/pr.js'), 'prReadCommand');
|
|
|
72
73
|
const inboxListCommand = lazy(() => import('./commands/inbox.js'), 'inboxListCommand');
|
|
73
74
|
const askHaystackCommand = lazy(() => import('./commands/ask.js'), 'askHaystackCommand');
|
|
74
75
|
const telemetryInstrumentCommand = lazy(() => import('./commands/telemetry.js'), 'telemetryInstrumentCommand');
|
|
76
|
+
const telemetrySettingsCommand = lazy(() => import('./commands/telemetry.js'), 'telemetrySettingsCommand');
|
|
77
|
+
const telemetryTokenCommand = lazy(() => import('./commands/telemetry-token.js'), 'telemetryTokenCommand');
|
|
75
78
|
const setupCommand = lazy(() => import('./commands/setup.js'), 'setupCommand');
|
|
76
79
|
const schemaCommand = lazy(() => import('./commands/schema-cmd.js'), 'schemaCommand');
|
|
77
80
|
const listSchemas = lazy(() => import('./commands/schema-cmd.js'), 'listSchemas');
|
|
@@ -148,6 +151,9 @@ program
|
|
|
148
151
|
.description('Set this repository up for `haystack verify` and start onboarding the app')
|
|
149
152
|
.option('-y, --yes', 'Make the changes without asking')
|
|
150
153
|
.option('--notes <file>', 'A JSON file (- for stdin) of what you know about running the app; see below')
|
|
154
|
+
.option('--app <dir>', 'The app whose telemetry init sets up, when the repository has several; see below')
|
|
155
|
+
.option('--origin <url>', 'A production origin of the app (repeat for several): browser capture accepts events only from these', (value, previous = []) => [...previous, value])
|
|
156
|
+
.option('--consent <choice>', 'How the site asks visitors for consent: its consent platform, or not-required when your user decided so')
|
|
151
157
|
.option('--json', 'The result as one JSON document on stdout (see `haystack schema init`)')
|
|
152
158
|
.addHelpText('after', `
|
|
153
159
|
There is no setup file: Haystack works out how to run the app from what the
|
|
@@ -175,6 +181,28 @@ an hour). The file is JSON with any of three notes, each plain text:
|
|
|
175
181
|
Leave out what you do not know. Onboarding still proves everything it takes
|
|
176
182
|
from the notes. New notes start a new onboarding; the same notes again do not.
|
|
177
183
|
|
|
184
|
+
Every run also plans telemetry for the app: --app names it when the repository
|
|
185
|
+
has several (init never picks one), --origin its production origins and
|
|
186
|
+
--consent the consent choice; until they are given, only telemetry waits and
|
|
187
|
+
its report says what to ask. Server telemetry: a Next.js app's next.config
|
|
188
|
+
gains the withHaystackTelemetry wrapper in its own export form; a Node server
|
|
189
|
+
whose build ends in a \`tsc\` compile gains \`haystack telemetry instrument\`
|
|
190
|
+
after it; the CLI becomes a checked, exact-version dependency through the
|
|
191
|
+
app's package manager; \`haystack telemetry settings --propose\` writes a
|
|
192
|
+
settings proposal that only your user approves (\`--approve\`). Each is a
|
|
193
|
+
change shown first like the others, and a failed install restores every file.
|
|
194
|
+
init never mints the ingest token: a repository admin runs
|
|
195
|
+
\`haystack telemetry token\` themselves. \`--json\` reports \`telemetry\`
|
|
196
|
+
(each part installed, manual or unsupported, and what Haystack last received)
|
|
197
|
+
and \`telemetrySteps\` (what the person does next, including the off switch
|
|
198
|
+
HAYSTACK_TELEMETRY=0).
|
|
199
|
+
|
|
200
|
+
Browser capture: --consent is cookiebot, onetrust:<category id>, not-required
|
|
201
|
+
or manual. init adds the self-hosted tag, its consent wiring and the build
|
|
202
|
+
step that publishes the route manifest (\`haystack capture manifest\`, which
|
|
203
|
+
never fails the build), registers the app for its key only with --yes, and
|
|
204
|
+
reports the Content-Security-Policy lines the tag needs without editing them.
|
|
205
|
+
|
|
178
206
|
Exit codes:
|
|
179
207
|
0 set up: onboarding is running or the app is ready
|
|
180
208
|
1 the command failed
|
|
@@ -245,7 +273,7 @@ const verify = program
|
|
|
245
273
|
.option('--no-wait', 'Print the crawl\'s current state and return without waiting')
|
|
246
274
|
.option('--minutes <n>', 'How long the crawl may take, 1-30 (default: .haystack.json crawl.minutes, else 3)')
|
|
247
275
|
.addOption(new Option('--pool <pool>', 'Where the crawl\'s clones are created: fleet, freestyle or auto (default auto)').hideHelp())
|
|
248
|
-
.option('--intent <sentence>', 'What
|
|
276
|
+
.option('--intent <sentence>', 'What you were asked to do, in the user\'s words (the task, not what your code does): the judge checks the app against it')
|
|
249
277
|
.option('--idea <text>', 'Something to try in the app, said as you would to a tester (repeat for more); each is explored first', (value, previous = []) => [...previous, value])
|
|
250
278
|
.option('--ideas-file <path>', 'A JSON array of more ideas')
|
|
251
279
|
.option('--json', 'The crawl as one JSON document on stdout (see `haystack schema verify`)')
|
|
@@ -259,8 +287,8 @@ one it finds was cancelled, stopped before finishing or is being cancelled, it
|
|
|
259
287
|
submits the capture (the service starts the crawl, or runs the stopped one
|
|
260
288
|
again), says so, and follows that crawl.
|
|
261
289
|
|
|
262
|
-
The coding agent that made the change can say what it
|
|
263
|
-
what to try (--idea, as many as it likes, or --ideas-file). Each idea is
|
|
290
|
+
The coding agent that made the change can say what it was asked to do (--intent:
|
|
291
|
+
the task in the user's words, not a description of its code) and what to try (--idea, as many as it likes, or --ideas-file). Each idea is
|
|
264
292
|
explored before anything else, and the answer says what became of it: tried,
|
|
265
293
|
could not try (and why), or not finished in time. Other ideas make another
|
|
266
294
|
crawl of the same code; the same ones again follow the same crawl.
|
|
@@ -318,10 +346,12 @@ same way, builds and prepares the change when nothing has yet (the turn-end
|
|
|
318
346
|
hook usually has), and prints its brief: the functions the change touches, the
|
|
319
347
|
changed ones and those one step away first (every one is in --json), and
|
|
320
348
|
Haystack's own ideas of what could break, each with what to set up, what to do
|
|
321
|
-
and what to watch.
|
|
349
|
+
and what to watch. With capture set up (.haystack/capture.json), it also shows
|
|
350
|
+
the routes the change touches with their share of real users' sessions. It
|
|
351
|
+
never starts a crawl.
|
|
322
352
|
|
|
323
|
-
Then hand back what
|
|
324
|
-
haystack verify --intent "<what the
|
|
353
|
+
Then hand back what you were asked to do and what to try:
|
|
354
|
+
haystack verify --intent "<what you were asked to do, in the user's words>" --idea "<something to try>"
|
|
325
355
|
|
|
326
356
|
Exit codes: 0 with a brief; 2 when the run ended without one; 3 when the app's
|
|
327
357
|
onboarding is blocked; 1 when the command failed.
|
|
@@ -970,6 +1000,53 @@ Example:
|
|
|
970
1000
|
includeSymbolsFile: options.includeSymbols,
|
|
971
1001
|
includeSymbols: undefined,
|
|
972
1002
|
}), options.json));
|
|
1003
|
+
telemetry
|
|
1004
|
+
.command('settings')
|
|
1005
|
+
.description('Propose .haystack/telemetry-settings.json: the settings whose values telemetry may record as themselves')
|
|
1006
|
+
.option('--propose', 'Write candidates read from the typed source to .haystack/telemetry-settings.proposed.json for review')
|
|
1007
|
+
.option('--approve', 'Show the reviewed proposal\'s change to .haystack/telemetry-settings.json and make it the allowlist')
|
|
1008
|
+
.option('--dry-run', 'Print what would be written without writing')
|
|
1009
|
+
.option('--json', 'Machine-readable proposal')
|
|
1010
|
+
.addHelpText('after', `
|
|
1011
|
+
Server telemetry records every value as its shape and size only. A setting
|
|
1012
|
+
(a parameter or binding whose TypeScript type is a closed set of literals:
|
|
1013
|
+
'light' | 'dark', an enum, a boolean, an as-const array) is recorded as its
|
|
1014
|
+
literal only when it is in .haystack/telemetry-settings.json. --propose writes
|
|
1015
|
+
every candidate it finds to .haystack/telemetry-settings.proposed.json (approved
|
|
1016
|
+
entries kept as they are), refusing names and literals that look like identity
|
|
1017
|
+
or secrets (id, email, name, token, password, key, secret, address, phone).
|
|
1018
|
+
A proposal approves nothing: review it, then --approve shows the change to
|
|
1019
|
+
.haystack/telemetry-settings.json, writes it, and you commit it.
|
|
1020
|
+
`)
|
|
1021
|
+
.action((options) => runPublicCommand(() => telemetrySettingsCommand(options), options.json));
|
|
1022
|
+
telemetry
|
|
1023
|
+
.command('token')
|
|
1024
|
+
.description('Mint the server-telemetry ingest token for a repository\'s production (run it yourself, in your own terminal)')
|
|
1025
|
+
.option('--repo <owner/name>', 'GitHub repository whose production sends telemetry')
|
|
1026
|
+
.option('--rotate', 'Replace the active token; the old one stops working at once')
|
|
1027
|
+
.option('--out <file>', 'Where to write the token (default ~/.haystack/telemetry-ingest-tokens/<owner>__<name>-production.token)')
|
|
1028
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
1029
|
+
.addHelpText('after', `
|
|
1030
|
+
It needs admin or maintain permission on the repository and an interactive
|
|
1031
|
+
terminal: a coding agent or a script never runs it. The token is generated
|
|
1032
|
+
here and written to a file only you can read; it is never printed, and
|
|
1033
|
+
Haystack keeps only its SHA-256. Running it again mints nothing while a token
|
|
1034
|
+
is active and says whether that file holds it. Then set HAYSTACK_TELEMETRY=1,
|
|
1035
|
+
HAYSTACK_TELEMETRY_ENDPOINT and HAYSTACK_TELEMETRY_TOKEN in production's server
|
|
1036
|
+
environment (never a public or client-side variable).
|
|
1037
|
+
|
|
1038
|
+
Exit codes: 0 minted, or the file holds the active token; 1 failed;
|
|
1039
|
+
2 declined at the prompt; 3 another token is active and this file does not
|
|
1040
|
+
hold it (--rotate replaces it).
|
|
1041
|
+
`)
|
|
1042
|
+
.action((options) => runPublicCommand(() => telemetryTokenCommand(options)));
|
|
1043
|
+
program.command('capture').description('Real users\' journeys and devices: the tag `haystack init` adds')
|
|
1044
|
+
.command('manifest')
|
|
1045
|
+
.description('Publish the app\'s route manifest into its static output (the build step `haystack init` adds)')
|
|
1046
|
+
.option('--app <dir>', 'The app directory (default: the current directory)')
|
|
1047
|
+
.option('--rails-routes <file>', 'Rails: the routes file the rake task wrote')
|
|
1048
|
+
.option('--json', 'The release, counts and files written as JSON')
|
|
1049
|
+
.action((options) => runPublicCommand(() => captureManifestCommand(options), options.json));
|
|
973
1050
|
// ─── db ──────────────────────────────────────────────────────────────────────
|
|
974
1051
|
const dbProgram = program
|
|
975
1052
|
.command('db')
|
package/dist/schema.js
CHANGED
|
@@ -21,10 +21,10 @@ export const SCHEMA_VERSIONS = {
|
|
|
21
21
|
'cloud-verifier': '1.0.0',
|
|
22
22
|
'case-batch': '1.0.1',
|
|
23
23
|
verify: '1.0.4',
|
|
24
|
-
'pre-verify': '1.0.
|
|
24
|
+
'pre-verify': '1.0.2',
|
|
25
25
|
'verify-answer': '1.0.1',
|
|
26
26
|
'verify-onboarding': '1.0.0',
|
|
27
|
-
init: '1.0.
|
|
27
|
+
init: '1.0.2',
|
|
28
28
|
login: '1.0.0',
|
|
29
29
|
error: '1.0.0',
|
|
30
30
|
};
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* require() it; the instrumenter itself is ESM and is imported on first use.
|
|
7
7
|
*
|
|
8
8
|
* Per module: leave it alone unless it is git-tracked repository source
|
|
9
|
-
* outside node_modules; otherwise instrument it at its
|
|
10
|
-
* record its sites in this build's session directory for
|
|
11
|
-
* step. A module that is not instrumented is returned
|
|
9
|
+
* outside node_modules (and not middleware); otherwise instrument it at its
|
|
10
|
+
* repository path and record its sites in this build's session directory for
|
|
11
|
+
* the post-compile step. A module that is not instrumented is returned
|
|
12
|
+
* byte-for-byte with the map it came with. Rule 13(b): an instrumented module's
|
|
13
|
+
* map is composed through the previous loader's, and nothing this loader does
|
|
14
|
+
* can fail the build.
|
|
12
15
|
*/
|
|
13
16
|
const node_child_process_1 = require("node:child_process");
|
|
14
17
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -42,18 +45,44 @@ function trackedFiles(sourceRoot) {
|
|
|
42
45
|
}
|
|
43
46
|
return tracked;
|
|
44
47
|
}
|
|
45
|
-
|
|
48
|
+
/** Middleware (`middleware.*`, and Next 16's `proxy.*`) at the project root or in `src/`. Next may compile it for the
|
|
49
|
+
* Node runtime, where the bundler's own conditions would let it through; rule 13(b) says it is never instrumented. */
|
|
50
|
+
function isMiddleware(resourcePath, projectDirectory) {
|
|
51
|
+
const fromProject = (0, node_path_1.relative)(projectDirectory, resourcePath).split(node_path_1.sep).join('/');
|
|
52
|
+
const stem = fromProject.replace(/\.[cm]?[jt]sx?$/, '');
|
|
53
|
+
return stem !== fromProject && ['middleware', 'src/middleware', 'proxy', 'src/proxy'].includes(stem);
|
|
54
|
+
}
|
|
55
|
+
/** A previous loader's map: webpack hands an object or its JSON text. */
|
|
56
|
+
function inputMap(sourceMap) {
|
|
57
|
+
if (typeof sourceMap === 'string') {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(sourceMap);
|
|
60
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return sourceMap && typeof sourceMap === 'object' ? sourceMap : null;
|
|
67
|
+
}
|
|
68
|
+
async function instrument(context, source, sourceMap, options) {
|
|
46
69
|
const relativePath = (0, node_path_1.relative)(options.sourceRoot, context.resourcePath);
|
|
47
70
|
if (!relativePath || relativePath.startsWith('..') || (0, node_path_1.isAbsolute)(relativePath))
|
|
48
71
|
return null;
|
|
49
72
|
const sourcePath = relativePath.split(node_path_1.sep).join('/');
|
|
50
|
-
if (sourcePath.split('/').includes('node_modules') ||
|
|
73
|
+
if (sourcePath.split('/').includes('node_modules') ||
|
|
74
|
+
(options.tracked && !trackedFiles(options.sourceRoot).has(sourcePath))) {
|
|
51
75
|
return null;
|
|
52
76
|
}
|
|
77
|
+
if (isMiddleware(context.resourcePath, options.projectDirectory))
|
|
78
|
+
return null;
|
|
53
79
|
const result = (await loadTelemetry()).instrumentBundledModule(source, {
|
|
54
80
|
sourcePath,
|
|
81
|
+
absolutePath: context.resourcePath,
|
|
82
|
+
sourceRoot: options.sourceRoot,
|
|
55
83
|
controlChannel: options.controlChannel,
|
|
56
84
|
runtimeIntegrity: options.runtimeIntegrity,
|
|
85
|
+
inputSourceMap: inputMap(sourceMap),
|
|
57
86
|
});
|
|
58
87
|
if (result.kind === 'unchanged')
|
|
59
88
|
return null;
|
|
@@ -68,9 +97,15 @@ async function instrument(context, source, options) {
|
|
|
68
97
|
// ships unchanged and adds nothing to the manifest.
|
|
69
98
|
if (result.sites.length === 0)
|
|
70
99
|
return null;
|
|
71
|
-
const record = {
|
|
100
|
+
const record = {
|
|
101
|
+
sourcePath,
|
|
102
|
+
probes: result.probes,
|
|
103
|
+
sites: result.sites,
|
|
104
|
+
typed: result.typed,
|
|
105
|
+
settings: result.settings,
|
|
106
|
+
};
|
|
72
107
|
writeSessionRecord(context, options, MODULES_DIRECTORY, sourcePath, source, record);
|
|
73
|
-
return result.code;
|
|
108
|
+
return { code: result.code, map: result.map };
|
|
74
109
|
}
|
|
75
110
|
function writeSessionRecord(context, options, directoryName, sourcePath, source, record) {
|
|
76
111
|
// The record must be written by every build that ships this module, so a
|
|
@@ -86,8 +121,30 @@ function writeSessionRecord(context, options, directoryName, sourcePath, source,
|
|
|
86
121
|
(0, node_fs_1.writeFileSync)(temporary, JSON.stringify(record));
|
|
87
122
|
(0, node_fs_1.renameSync)(temporary, (0, node_path_1.join)(directory, `${name}.json`));
|
|
88
123
|
}
|
|
124
|
+
function load(context, source, sourceMap) {
|
|
125
|
+
const callback = context.async();
|
|
126
|
+
let options;
|
|
127
|
+
try {
|
|
128
|
+
options = context.getOptions();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
callback(null, source, sourceMap);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
instrument(context, source, sourceMap, options).then(result => result === null ? callback(null, source, sourceMap) : callback(null, result.code, result.map ?? undefined), (error) => {
|
|
135
|
+
// Ours failed (git, the transform, the session directory): the module ships exactly as it came.
|
|
136
|
+
try {
|
|
137
|
+
const sourcePath = (0, node_path_1.relative)(options.sourceRoot, context.resourcePath).split(node_path_1.sep).join('/');
|
|
138
|
+
const reason = String(error instanceof Error ? error.message : error).split('\n')[0].slice(0, 300);
|
|
139
|
+
writeSessionRecord(context, options, UNPARSED_DIRECTORY, sourcePath, source, { sourcePath, reason });
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// The report is best effort; the build is not.
|
|
143
|
+
}
|
|
144
|
+
callback(null, source, sourceMap);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
89
147
|
function haystackTelemetryNextLoader(source, sourceMap) {
|
|
90
|
-
|
|
91
|
-
instrument(this, source, this.getOptions()).then(code => code === null ? callback(null, source, sourceMap) : callback(null, code), (error) => callback(error instanceof Error ? error : new Error(String(error))));
|
|
148
|
+
load(this, source, sourceMap);
|
|
92
149
|
}
|
|
93
150
|
module.exports = haystackTelemetryNextLoader;
|
package/dist/telemetry/next.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { type FileProbeCounts, type InstrumentedSite } from '../commands/telemetry.js';
|
|
1
|
+
import { type BundledSettingsRecord, type BundledSourceIdentity, type FileProbeCounts, type InstrumentedSite } from '../commands/telemetry.js';
|
|
2
2
|
/** Options the config hands every loader invocation; JSON for Turbopack. */
|
|
3
3
|
export interface NextTelemetryLoaderOptions {
|
|
4
|
-
/** Repository root (git top level); every site path is relative to it. */
|
|
4
|
+
/** Repository root (git top level); every site path is relative to it. Without git: the Next project. */
|
|
5
5
|
sourceRoot: string;
|
|
6
|
+
/** Instrument only git-tracked files (false in a build without git: every non-node_modules source under sourceRoot). */
|
|
7
|
+
tracked: boolean;
|
|
8
|
+
sourceIdentity: BundledSourceIdentity;
|
|
9
|
+
/** The Next project (where next.config is): its middleware is never instrumented. */
|
|
10
|
+
projectDirectory: string;
|
|
6
11
|
/** This build's hand-off directory between loaders and the post-compile step. */
|
|
7
12
|
sessionDirectory: string;
|
|
8
13
|
controlChannel: string;
|
|
@@ -13,8 +18,11 @@ export interface NextTelemetryModuleRecord {
|
|
|
13
18
|
sourcePath: string;
|
|
14
19
|
probes: FileProbeCounts;
|
|
15
20
|
sites: InstrumentedSite[];
|
|
21
|
+
/** Rule 9(b)/(c): read with its types (declared fields, setting literals); false: shape-only. */
|
|
22
|
+
typed: boolean;
|
|
23
|
+
settings: BundledSettingsRecord;
|
|
16
24
|
}
|
|
17
|
-
/** A server module shipped uninstrumented because Babel could not parse it. */
|
|
25
|
+
/** A server module shipped uninstrumented because Babel could not parse it or the transform failed. */
|
|
18
26
|
export interface NextTelemetryUnparsedRecord {
|
|
19
27
|
sourcePath: string;
|
|
20
28
|
reason: string;
|