@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.
Files changed (49) hide show
  1. package/dist/assets/capture/capture.cb4204fcc997d8e8.js +2 -0
  2. package/dist/assets/capture/release.json +4 -0
  3. package/dist/assets/telemetry/runtime.cjs +829 -1254
  4. package/dist/capture/adapters/client-routes.js +383 -0
  5. package/dist/capture/adapters/django.js +127 -0
  6. package/dist/capture/adapters/files.js +77 -0
  7. package/dist/capture/adapters/index.js +64 -0
  8. package/dist/capture/adapters/jsx-edit.js +81 -0
  9. package/dist/capture/adapters/next.js +327 -0
  10. package/dist/capture/adapters/nuxt.js +192 -0
  11. package/dist/capture/adapters/rails.js +171 -0
  12. package/dist/capture/adapters/react-router.js +432 -0
  13. package/dist/capture/adapters/sveltekit.js +102 -0
  14. package/dist/capture/adapters/types.js +4 -0
  15. package/dist/capture/adapters/vite.js +121 -0
  16. package/dist/capture/app-config.js +106 -0
  17. package/dist/capture/consent.js +127 -0
  18. package/dist/capture/csp.js +331 -0
  19. package/dist/capture/html.js +74 -0
  20. package/dist/capture/js-ast.js +400 -0
  21. package/dist/capture/manifest.js +95 -0
  22. package/dist/capture/project.js +177 -0
  23. package/dist/capture/route-pattern.js +119 -0
  24. package/dist/capture/script-release.js +47 -0
  25. package/dist/capture/tag.js +74 -0
  26. package/dist/capture-step.js +53 -0
  27. package/dist/commands/capture-brief.js +92 -0
  28. package/dist/commands/capture-contract.js +46 -0
  29. package/dist/commands/capture-manifest.js +78 -0
  30. package/dist/commands/init-capture.js +409 -0
  31. package/dist/commands/init-telemetry.js +1011 -0
  32. package/dist/commands/init.js +75 -9
  33. package/dist/commands/server-telemetry-contract.d.ts +66 -0
  34. package/dist/commands/server-telemetry-contract.js +127 -0
  35. package/dist/commands/telemetry-token.js +238 -0
  36. package/dist/commands/telemetry.d.ts +161 -8
  37. package/dist/commands/telemetry.js +940 -158
  38. package/dist/commands/verify-onboarding.js +5 -1
  39. package/dist/commands/verify.js +54 -7
  40. package/dist/index.js +83 -6
  41. package/dist/schema.js +2 -2
  42. package/dist/telemetry/next-loader.cjs +66 -9
  43. package/dist/telemetry/next.d.ts +11 -3
  44. package/dist/telemetry/next.js +95 -15
  45. package/dist/telemetry/typed-source.d.ts +47 -0
  46. package/dist/telemetry/typed-source.js +379 -0
  47. package/package.json +4 -2
  48. package/schemas/init.v1.json +63 -4
  49. package/schemas/pre-verify.v1.json +60 -3
@@ -35,7 +35,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from 'nod
35
35
  import { tmpdir } from 'node:os';
36
36
  import { join, resolve } from 'node:path';
37
37
  import { fileURLToPath } from 'node:url';
38
- import { authenticateTelemetryRuntime, BUNDLED_SOURCE_EXTENSIONS, writeBundledTelemetryRuntime, } from '../commands/telemetry.js';
38
+ import { authenticateTelemetryRuntime, BUNDLED_SOURCE_EXTENSIONS, settingsSummary, writeBundledTelemetryRuntime, writeInertTelemetryRuntime, } from '../commands/telemetry.js';
39
39
  const PRODUCTION_BUILD_PHASE = 'phase-production-build';
40
40
  const SESSION_ENVIRONMENT = '__HAYSTACK_TELEMETRY_NEXT_SESSION';
41
41
  // Must equal the directory names in ./next-loader.cts.
@@ -52,30 +52,88 @@ const LOADER_PATH = fileURLToPath(new URL('./next-loader.cjs', import.meta.url))
52
52
  function plainObject(value) {
53
53
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
54
54
  }
55
- function gitOutput(cwd, args) {
55
+ /** Site paths are repository-relative so they match the crawl's blast radius (`apps/web/src/...` in a monorepo). A build
56
+ * without git (a Docker build whose context has no .git) still instruments: paths are relative to the Next project and
57
+ * every source module the server compiles counts, which the manifest's source_identity says. */
58
+ function sourceIdentity(projectDirectory) {
56
59
  try {
57
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 256 * 1024 * 1024 });
60
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], {
61
+ cwd: projectDirectory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
62
+ }).trim();
63
+ return { sourceRoot: realpathSync(top), tracked: true, identity: { git: true, lost: null } };
58
64
  }
59
- catch (error) {
60
- throw new Error(`Haystack telemetry needs the app's git checkout to name source files (git ${args.join(' ')} failed in ${cwd}): ` +
61
- `${error instanceof Error ? error.message : String(error)}`);
65
+ catch {
66
+ return {
67
+ sourceRoot: projectDirectory,
68
+ tracked: false,
69
+ identity: {
70
+ git: false,
71
+ lost: 'no git checkout: site paths are relative to the Next project, not the repository (in a monorepo they do not ' +
72
+ "join the change's paths), and untracked source files the server compiles are instrumented too",
73
+ },
74
+ };
75
+ }
76
+ }
77
+ /** Rule 13(b): telemetry never fails the customer's build. Anything that stops it is said once, and the build goes on
78
+ * without it; the build output still gets an inert preload and a report saying why. */
79
+ function notInstrumented(reason) {
80
+ console.log(`Haystack telemetry: not added to this build: ${reason}`);
81
+ }
82
+ /** The config with only the step that leaves the inert preload and the report (so a start command that preloads it works). */
83
+ function withInertRuntime(config, reason) {
84
+ notInstrumented(reason);
85
+ const compiler = plainObject(config.compiler);
86
+ const originalAfterCompile = typeof compiler.runAfterProductionCompile === 'function'
87
+ ? compiler.runAfterProductionCompile
88
+ : null;
89
+ return {
90
+ ...config,
91
+ compiler: {
92
+ ...compiler,
93
+ async runAfterProductionCompile(context) {
94
+ if (originalAfterCompile)
95
+ await originalAfterCompile(context);
96
+ writeInert(resolve(context.projectDir, context.distDir), reason);
97
+ },
98
+ },
99
+ };
100
+ }
101
+ function writeInert(distDirectory, reason, unparsed = []) {
102
+ try {
103
+ writeInertTelemetryRuntime({ outputDirectory: distDirectory, bundler: 'next', reason, unparsed });
104
+ }
105
+ catch {
106
+ // A build output that cannot be written to is the build's own failure to report, not ours.
62
107
  }
63
108
  }
64
109
  export function withHaystackTelemetry(nextConfig) {
65
110
  return async (phase, context) => {
66
111
  const resolved = typeof nextConfig === 'function' ? await nextConfig(phase, context) : nextConfig;
67
- return phase === PRODUCTION_BUILD_PHASE ? instrumentProductionBuild(resolved) : resolved;
112
+ if (phase !== PRODUCTION_BUILD_PHASE)
113
+ return resolved;
114
+ try {
115
+ return instrumentProductionBuild(resolved);
116
+ }
117
+ catch (error) {
118
+ try {
119
+ return withInertRuntime(resolved, error instanceof Error ? error.message : String(error));
120
+ }
121
+ catch {
122
+ return resolved;
123
+ }
124
+ }
68
125
  };
69
126
  }
70
127
  function instrumentProductionBuild(config) {
71
128
  if (plainObject(config.experimental).turbopackFileSystemCacheForBuild) {
72
129
  // A persisted Turbopack result skips the loader, so that module's sites
73
130
  // would never reach the manifest and its probes would count nothing.
74
- throw new Error('Haystack telemetry cannot instrument a build that reuses cached Turbopack modules; turn off experimental.turbopackFileSystemCacheForBuild');
131
+ return withInertRuntime(config, 'it cannot instrument a build that reuses cached Turbopack modules; turn off experimental.turbopackFileSystemCacheForBuild to add it');
75
132
  }
76
- // Site paths are repository-relative so they match the crawl's blast radius
77
- // (`apps/web/src/...` in a monorepo), not relative to the Next project.
78
- const sourceRoot = realpathSync(gitOutput(process.cwd(), ['rev-parse', '--show-toplevel']).trim());
133
+ const projectDirectory = realpathSync(process.cwd());
134
+ const { sourceRoot, tracked, identity } = sourceIdentity(projectDirectory);
135
+ if (!identity.git)
136
+ console.log(`Haystack telemetry: ${identity.lost}.`);
79
137
  // Evaluating this config has no side effects beyond naming the session:
80
138
  // `next build` re-evaluates it in every worker it spawns (page-data and
81
139
  // static-generation workers too), and loaders create what they write.
@@ -87,6 +145,9 @@ function instrumentProductionBuild(config) {
87
145
  .update(`next\0${sourceRoot}`).digest('hex').slice(0, 24)}`;
88
146
  const loaderOptions = {
89
147
  sourceRoot,
148
+ tracked,
149
+ sourceIdentity: identity,
150
+ projectDirectory,
90
151
  sessionDirectory,
91
152
  controlChannel,
92
153
  runtimeIntegrity: authenticateTelemetryRuntime(controlChannel).integrity,
@@ -94,7 +155,9 @@ function instrumentProductionBuild(config) {
94
155
  const loader = { loader: LOADER_PATH, options: loaderOptions };
95
156
  // Turbopack (Next 16's default bundler): server-side ("node") modules outside
96
157
  // node_modules ("foreign"). Edge and browser contexts are left alone: the
97
- // runtime is Node-only.
158
+ // runtime is Node-only. The loader also leaves "use client" modules (their
159
+ // server copy is the SSR render of browser code), modules declaring
160
+ // `export const runtime = 'edge'`, and middleware alone.
98
161
  const turbopack = plainObject(config.turbopack);
99
162
  const rules = { ...plainObject(turbopack.rules) };
100
163
  for (const extension of BUNDLED_SOURCE_EXTENSIONS) {
@@ -133,9 +196,19 @@ function instrumentProductionBuild(config) {
133
196
  compiler: {
134
197
  ...compiler,
135
198
  async runAfterProductionCompile(context) {
199
+ // The application's own hook keeps its own failures.
136
200
  if (originalAfterCompile)
137
201
  await originalAfterCompile(context);
138
- writeNextTelemetryRuntime(loaderOptions, resolve(context.projectDir, context.distDir));
202
+ const distDirectory = resolve(context.projectDir, context.distDir);
203
+ try {
204
+ writeNextTelemetryRuntime(loaderOptions, distDirectory);
205
+ }
206
+ catch (error) {
207
+ // Instrumented modules without an installed runtime pass every value through.
208
+ const reason = error instanceof Error ? error.message : String(error);
209
+ notInstrumented(reason);
210
+ writeInert(distDirectory, reason);
211
+ }
139
212
  },
140
213
  },
141
214
  };
@@ -144,13 +217,18 @@ function writeNextTelemetryRuntime(options, distDirectory) {
144
217
  const modules = readRecords(join(options.sessionDirectory, MODULES_DIRECTORY));
145
218
  const unparsed = readRecords(join(options.sessionDirectory, UNPARSED_DIRECTORY));
146
219
  if (modules.length === 0) {
147
- throw new Error(`Haystack telemetry: the bundler compiled no instrumentable tracked server module through its loader${unparsed.length > 0 ? ` (${unparsed.length} could not be parsed)` : ''}; nothing was instrumented`);
220
+ rmSync(options.sessionDirectory, { recursive: true, force: true });
221
+ const reason = `the bundler compiled no instrumentable${options.tracked ? ' tracked' : ''} server module through its loader${unparsed.length > 0 ? ` (${unparsed.length} could not be parsed or transformed)` : ''}`;
222
+ notInstrumented(reason);
223
+ writeInert(distDirectory, reason, unparsed);
224
+ return;
148
225
  }
149
226
  const { registerPath, manifest } = writeBundledTelemetryRuntime({
150
227
  outputDirectory: distDirectory,
151
228
  controlChannel: options.controlChannel,
152
229
  bundler: 'next',
153
230
  sourceRoot: options.sourceRoot,
231
+ sourceIdentity: options.sourceIdentity,
154
232
  modules,
155
233
  unparsed,
156
234
  });
@@ -158,9 +236,11 @@ function writeNextTelemetryRuntime(options, distDirectory) {
158
236
  // `next build` spawns next resolve the same (now empty) session.
159
237
  rmSync(options.sessionDirectory, { recursive: true, force: true });
160
238
  console.log(`Haystack telemetry: instrumented ${modules.length} server module(s), ${manifest.sites.length} site(s); build ${manifest.build_id}.`);
239
+ for (const line of settingsSummary(manifest.settings, modules.filter(module => module.typed).length, modules.length))
240
+ console.log(line);
161
241
  if (manifest.skipped_unparsed_files.length > 0) {
162
242
  // Named, never silent: these modules run but count nothing.
163
- console.log(`Haystack telemetry: skipped ${manifest.skipped_unparsed_files.length} server module(s) Babel could not parse (shipped uninstrumented; listed in instrumentation.json skipped_unparsed_files):`);
243
+ console.log(`Haystack telemetry: shipped ${manifest.skipped_unparsed_files.length} server module(s) uninstrumented (Babel could not parse or transform them; listed in instrumentation.json skipped_unparsed_files):`);
164
244
  for (const file of manifest.skipped_unparsed_files)
165
245
  console.log(` ${file.source_path}: ${file.reason}`);
166
246
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CAPTURE-V1 rule 9(b) and 9(c): what a site's TypeScript declares, read from the ORIGINAL typed source before any
3
+ * compiler erases it. A parameter or binding annotated with a union of literals, a boolean, an enum, an `as const` array
4
+ * or object (through `(typeof X)[number]`, `typeof X[keyof typeof X]` or `keyof typeof X`) has a LITERAL DOMAIN: a
5
+ * settings candidate. An object type (inline, interface, alias, intersection, Partial/Required/Readonly/Pick/Omit) has
6
+ * DECLARED FIELDS: the names whose presence the runtime records.
7
+ *
8
+ * Types resolve within the file and through one hop of relative imports (`import type { Mode } from './types'`); a
9
+ * name that resolves to nothing known, a generic, a computed enum member or a template type has no domain and no
10
+ * fields, which leaves the site shape-only. Resolution is by declared identifier, never by text similarity.
11
+ */
12
+ type Node = any;
13
+ export type SettingLiteral = string | number | boolean;
14
+ /** A file's type declarations by name, and where its imported names come from. */
15
+ export interface TypeIndex {
16
+ aliases: Map<string, Node>;
17
+ interfaces: Map<string, Node[]>;
18
+ enums: Map<string, Node>;
19
+ /** `const X = [...] as const` and `const X = {...} as const`: the literal array or object. */
20
+ constants: Map<string, Node>;
21
+ imports: Map<string, {
22
+ source: string;
23
+ imported: string;
24
+ }>;
25
+ /** Names the file exports under another local name (`export { Local as Exported }`). */
26
+ exportedAs: Map<string, string>;
27
+ }
28
+ /** Finds an imported declaration: the other file's index, or null when the specifier is not a readable relative file. */
29
+ export type TypeImportResolver = (specifier: string) => TypeIndex | null;
30
+ /** Index one parsed program's top-level type declarations, `as const` literals and imports. */
31
+ export declare function buildTypeIndex(program: Node): TypeIndex;
32
+ interface Domain {
33
+ literals: SettingLiteral[];
34
+ typeNames: string[];
35
+ }
36
+ /** Rule 9(c): the literal domain a type annotation declares, with the type names it went through (for the refusal
37
+ * check), or null when the type is not a closed set of literals. */
38
+ export declare function literalDomain(annotation: Node, index: TypeIndex, resolve: TypeImportResolver | null): Domain | null;
39
+ /** Rule 9(b): the field names an annotation's object type declares, in declaration order. */
40
+ export declare function declaredFields(annotation: Node, index: TypeIndex, resolve: TypeImportResolver | null): string[];
41
+ /** The declared type of one member of an annotation's object type (a destructured parameter's property). */
42
+ export declare function memberType(annotation: Node, key: string, index: TypeIndex, resolve: TypeImportResolver | null): {
43
+ annotation: Node;
44
+ index: TypeIndex;
45
+ resolve: TypeImportResolver | null;
46
+ } | null;
47
+ export {};
@@ -0,0 +1,379 @@
1
+ /**
2
+ * CAPTURE-V1 rule 9(b) and 9(c): what a site's TypeScript declares, read from the ORIGINAL typed source before any
3
+ * compiler erases it. A parameter or binding annotated with a union of literals, a boolean, an enum, an `as const` array
4
+ * or object (through `(typeof X)[number]`, `typeof X[keyof typeof X]` or `keyof typeof X`) has a LITERAL DOMAIN: a
5
+ * settings candidate. An object type (inline, interface, alias, intersection, Partial/Required/Readonly/Pick/Omit) has
6
+ * DECLARED FIELDS: the names whose presence the runtime records.
7
+ *
8
+ * Types resolve within the file and through one hop of relative imports (`import type { Mode } from './types'`); a
9
+ * name that resolves to nothing known, a generic, a computed enum member or a template type has no domain and no
10
+ * fields, which leaves the site shape-only. Resolution is by declared identifier, never by text similarity.
11
+ */
12
+ const MAX_DEPTH = 8;
13
+ const MAX_DECLARED_FIELDS = 256;
14
+ function unwrapExport(statement) {
15
+ return statement?.type === 'ExportNamedDeclaration' || statement?.type === 'ExportDefaultDeclaration'
16
+ ? statement.declaration ?? null
17
+ : statement;
18
+ }
19
+ function constAssertion(expression) {
20
+ let current = expression;
21
+ while (current?.type === 'TSSatisfiesExpression')
22
+ current = current.expression;
23
+ if (current?.type === 'TSAsExpression' && current.typeAnnotation?.type === 'TSTypeReference' &&
24
+ current.typeAnnotation.typeName?.type === 'Identifier' && current.typeAnnotation.typeName.name === 'const') {
25
+ return current.expression;
26
+ }
27
+ return null;
28
+ }
29
+ /** Index one parsed program's top-level type declarations, `as const` literals and imports. */
30
+ export function buildTypeIndex(program) {
31
+ const index = {
32
+ aliases: new Map(), interfaces: new Map(), enums: new Map(), constants: new Map(), imports: new Map(),
33
+ exportedAs: new Map(),
34
+ };
35
+ for (const statement of program?.body ?? []) {
36
+ if (statement?.type === 'ImportDeclaration' && typeof statement.source?.value === 'string') {
37
+ for (const specifier of statement.specifiers ?? []) {
38
+ if (specifier.type !== 'ImportSpecifier' || specifier.local?.type !== 'Identifier')
39
+ continue;
40
+ const imported = specifier.imported?.type === 'Identifier' ? specifier.imported.name : specifier.imported?.value;
41
+ if (typeof imported === 'string') {
42
+ index.imports.set(specifier.local.name, { source: statement.source.value, imported });
43
+ }
44
+ }
45
+ continue;
46
+ }
47
+ if (statement?.type === 'ExportNamedDeclaration' && !statement.declaration && !statement.source) {
48
+ for (const specifier of statement.specifiers ?? []) {
49
+ const exported = specifier.exported?.type === 'Identifier' ? specifier.exported.name : specifier.exported?.value;
50
+ if (specifier.local?.type === 'Identifier' && typeof exported === 'string') {
51
+ index.exportedAs.set(exported, specifier.local.name);
52
+ }
53
+ }
54
+ continue;
55
+ }
56
+ const declaration = unwrapExport(statement);
57
+ if (!declaration)
58
+ continue;
59
+ if (declaration.type === 'TSTypeAliasDeclaration' && declaration.id?.name) {
60
+ index.aliases.set(declaration.id.name, declaration.typeAnnotation);
61
+ }
62
+ else if (declaration.type === 'TSInterfaceDeclaration' && declaration.id?.name) {
63
+ index.interfaces.set(declaration.id.name, [...index.interfaces.get(declaration.id.name) ?? [], declaration]);
64
+ }
65
+ else if (declaration.type === 'TSEnumDeclaration' && declaration.id?.name) {
66
+ index.enums.set(declaration.id.name, declaration);
67
+ }
68
+ else if (declaration.type === 'VariableDeclaration' && declaration.kind === 'const') {
69
+ for (const declarator of declaration.declarations ?? []) {
70
+ const literal = declarator.id?.type === 'Identifier' ? constAssertion(declarator.init) : null;
71
+ if (literal)
72
+ index.constants.set(declarator.id.name, literal);
73
+ }
74
+ }
75
+ }
76
+ return index;
77
+ }
78
+ function lookup(name, scope, namespace) {
79
+ const { index } = scope;
80
+ if (namespace === 'type') {
81
+ if (index.aliases.has(name))
82
+ return { kind: 'alias', node: index.aliases.get(name), scope };
83
+ if (index.interfaces.has(name))
84
+ return { kind: 'interface', nodes: index.interfaces.get(name), scope };
85
+ }
86
+ if (index.enums.has(name))
87
+ return { kind: 'enum', node: index.enums.get(name), scope };
88
+ if (namespace === 'value' && index.constants.has(name))
89
+ return { kind: 'constant', node: index.constants.get(name), scope };
90
+ const imported = index.imports.get(name);
91
+ // One hop: an imported file's own imports are not followed.
92
+ if (imported && scope.resolve && scope.depth === 0) {
93
+ const other = scope.resolve(imported.source);
94
+ if (!other)
95
+ return null;
96
+ const local = other.exportedAs.get(imported.imported) ?? imported.imported;
97
+ return lookup(local, { index: other, resolve: null, depth: 1 }, namespace);
98
+ }
99
+ return null;
100
+ }
101
+ function literalValue(node) {
102
+ if (node?.type === 'StringLiteral')
103
+ return node.value;
104
+ if (node?.type === 'NumericLiteral')
105
+ return node.value;
106
+ if (node?.type === 'BooleanLiteral')
107
+ return node.value;
108
+ if (node?.type === 'UnaryExpression' && node.operator === '-' && node.argument?.type === 'NumericLiteral') {
109
+ return -node.argument.value;
110
+ }
111
+ if (node?.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
112
+ return node.quasis[0].value.cooked;
113
+ }
114
+ return undefined;
115
+ }
116
+ function enumValues(declaration) {
117
+ const members = declaration.body?.members ?? declaration.members ?? [];
118
+ const values = [];
119
+ let next = 0;
120
+ for (const member of members) {
121
+ if (!member.initializer) {
122
+ if (next === null)
123
+ return null;
124
+ values.push(next);
125
+ next += 1;
126
+ continue;
127
+ }
128
+ const value = literalValue(member.initializer);
129
+ if (typeof value === 'string') {
130
+ values.push(value);
131
+ next = null;
132
+ }
133
+ else if (typeof value === 'number') {
134
+ values.push(value);
135
+ next = value + 1;
136
+ }
137
+ else {
138
+ // A computed member: its value is only known when the program runs.
139
+ return null;
140
+ }
141
+ }
142
+ return values;
143
+ }
144
+ function enumMember(declaration, memberName) {
145
+ const values = enumValues(declaration);
146
+ if (!values)
147
+ return null;
148
+ const members = declaration.body?.members ?? declaration.members ?? [];
149
+ const position = members.findIndex((member) => (member.id?.type === 'Identifier' ? member.id.name : member.id?.value) === memberName);
150
+ return position < 0 ? null : [values[position]];
151
+ }
152
+ function constantLiterals(node, part) {
153
+ const values = [];
154
+ if (part === 'elements') {
155
+ if (node?.type !== 'ArrayExpression')
156
+ return null;
157
+ for (const element of node.elements) {
158
+ const value = literalValue(element);
159
+ if (value === undefined)
160
+ return null;
161
+ values.push(value);
162
+ }
163
+ return values;
164
+ }
165
+ if (node?.type !== 'ObjectExpression')
166
+ return null;
167
+ for (const property of node.properties) {
168
+ if (property.type !== 'ObjectProperty' || property.computed)
169
+ return null;
170
+ if (part === 'keys') {
171
+ const key = property.key?.type === 'Identifier' ? property.key.name : literalValue(property.key);
172
+ if (key === undefined || typeof key === 'boolean')
173
+ return null;
174
+ values.push(String(key));
175
+ }
176
+ else {
177
+ const value = literalValue(property.value);
178
+ if (value === undefined)
179
+ return null;
180
+ values.push(value);
181
+ }
182
+ }
183
+ return values;
184
+ }
185
+ function queryName(node) {
186
+ return node?.type === 'TSTypeQuery' && node.exprName?.type === 'Identifier' ? node.exprName.name : null;
187
+ }
188
+ function referenceName(node) {
189
+ const typeName = node?.typeName;
190
+ if (typeName?.type === 'Identifier')
191
+ return { name: typeName.name, member: null };
192
+ if (typeName?.type === 'TSQualifiedName' && typeName.left?.type === 'Identifier' && typeName.right?.type === 'Identifier') {
193
+ return { name: typeName.left.name, member: typeName.right.name };
194
+ }
195
+ return null;
196
+ }
197
+ function hasTypeArguments(node) {
198
+ return Boolean((node?.typeArguments ?? node?.typeParameters)?.params?.length);
199
+ }
200
+ function domainOf(node, scope, depth) {
201
+ if (!node || depth > MAX_DEPTH)
202
+ return null;
203
+ switch (node.type) {
204
+ case 'TSTypeAnnotation':
205
+ case 'TSParenthesizedType':
206
+ return domainOf(node.typeAnnotation, scope, depth + 1);
207
+ case 'TSBooleanKeyword':
208
+ return { literals: [true, false], typeNames: [] };
209
+ case 'TSLiteralType': {
210
+ const value = literalValue(node.literal);
211
+ return value === undefined ? null : { literals: [value], typeNames: [] };
212
+ }
213
+ case 'TSUndefinedKeyword':
214
+ case 'TSNullKeyword':
215
+ case 'TSVoidKeyword':
216
+ // Nullish members are the vocabulary's own categories, never literals.
217
+ return { literals: [], typeNames: [] };
218
+ case 'TSUnionType': {
219
+ const literals = [];
220
+ const typeNames = [];
221
+ for (const member of node.types) {
222
+ const domain = domainOf(member, scope, depth + 1);
223
+ if (!domain)
224
+ return null;
225
+ for (const literal of domain.literals)
226
+ if (!literals.some(known => Object.is(known, literal)))
227
+ literals.push(literal);
228
+ typeNames.push(...domain.typeNames);
229
+ }
230
+ return { literals, typeNames };
231
+ }
232
+ case 'TSTypeOperator': {
233
+ if (node.operator === 'readonly')
234
+ return domainOf(node.typeAnnotation, scope, depth + 1);
235
+ const name = node.operator === 'keyof' ? queryName(node.typeAnnotation) : null;
236
+ const declaration = name ? lookup(name, scope, 'value') : null;
237
+ if (declaration?.kind !== 'constant')
238
+ return null;
239
+ const literals = constantLiterals(declaration.node, 'keys');
240
+ return literals ? { literals, typeNames: [name] } : null;
241
+ }
242
+ case 'TSIndexedAccessType': {
243
+ const name = queryName(node.objectType);
244
+ const declaration = name ? lookup(name, scope, 'value') : null;
245
+ if (declaration?.kind !== 'constant')
246
+ return null;
247
+ // (typeof X)[number] over an `as const` array; typeof X[keyof typeof X] over an `as const` object.
248
+ if (node.indexType?.type === 'TSNumberKeyword') {
249
+ const literals = constantLiterals(declaration.node, 'elements');
250
+ return literals ? { literals, typeNames: [name] } : null;
251
+ }
252
+ if (node.indexType?.type === 'TSTypeOperator' && node.indexType.operator === 'keyof' &&
253
+ queryName(node.indexType.typeAnnotation) === name) {
254
+ const literals = constantLiterals(declaration.node, 'values');
255
+ return literals ? { literals, typeNames: [name] } : null;
256
+ }
257
+ return null;
258
+ }
259
+ case 'TSTypeReference': {
260
+ const reference = referenceName(node);
261
+ if (!reference || hasTypeArguments(node))
262
+ return null;
263
+ const declaration = lookup(reference.name, scope, 'type');
264
+ if (!declaration)
265
+ return null;
266
+ if (declaration.kind === 'enum') {
267
+ const literals = reference.member ? enumMember(declaration.node, reference.member) : enumValues(declaration.node);
268
+ return literals ? { literals, typeNames: [reference.name] } : null;
269
+ }
270
+ if (reference.member || declaration.kind !== 'alias')
271
+ return null;
272
+ const domain = domainOf(declaration.node, declaration.scope, depth + 1);
273
+ return domain ? { literals: domain.literals, typeNames: [reference.name, ...domain.typeNames] } : null;
274
+ }
275
+ default:
276
+ return null;
277
+ }
278
+ }
279
+ /** Rule 9(c): the literal domain a type annotation declares, with the type names it went through (for the refusal
280
+ * check), or null when the type is not a closed set of literals. */
281
+ export function literalDomain(annotation, index, resolve) {
282
+ const domain = domainOf(annotation, { index, resolve, depth: 0 }, 0);
283
+ return domain && domain.literals.length > 0 ? domain : null;
284
+ }
285
+ function memberKey(member) {
286
+ if (member?.type !== 'TSPropertySignature' || member.computed)
287
+ return null;
288
+ if (member.key?.type === 'Identifier')
289
+ return member.key.name;
290
+ if (member.key?.type === 'StringLiteral')
291
+ return member.key.value;
292
+ return null;
293
+ }
294
+ function keyLiterals(node, scope) {
295
+ const domain = domainOf(node, scope, 0);
296
+ return domain && domain.literals.every(literal => typeof literal === 'string') ? domain.literals : null;
297
+ }
298
+ /** Each declared member of an object type, by name, with the scope its type resolves in. */
299
+ function membersOf(node, scope, depth, out) {
300
+ if (!node || depth > MAX_DEPTH)
301
+ return false;
302
+ switch (node.type) {
303
+ case 'TSTypeAnnotation':
304
+ case 'TSParenthesizedType':
305
+ return membersOf(node.typeAnnotation, scope, depth + 1, out);
306
+ case 'TSTypeLiteral':
307
+ for (const member of node.members) {
308
+ const key = memberKey(member);
309
+ if (key !== null && !out.has(key))
310
+ out.set(key, { type: member.typeAnnotation, scope });
311
+ }
312
+ return true;
313
+ case 'TSIntersectionType':
314
+ case 'TSUnionType': {
315
+ let any = false;
316
+ for (const member of node.types)
317
+ any = membersOf(member, scope, depth + 1, out) || any;
318
+ return any;
319
+ }
320
+ case 'TSTypeReference': {
321
+ const reference = referenceName(node);
322
+ if (!reference || reference.member)
323
+ return false;
324
+ const typeArguments = (node.typeArguments ?? node.typeParameters)?.params ?? [];
325
+ if (['Partial', 'Required', 'Readonly', 'NonNullable'].includes(reference.name) && typeArguments.length === 1) {
326
+ return membersOf(typeArguments[0], scope, depth + 1, out);
327
+ }
328
+ if ((reference.name === 'Pick' || reference.name === 'Omit') && typeArguments.length === 2) {
329
+ const all = new Map();
330
+ if (!membersOf(typeArguments[0], scope, depth + 1, all))
331
+ return false;
332
+ const keys = keyLiterals(typeArguments[1], scope);
333
+ if (!keys)
334
+ return false;
335
+ for (const [key, value] of all) {
336
+ if ((reference.name === 'Pick') === keys.includes(key) && !out.has(key))
337
+ out.set(key, value);
338
+ }
339
+ return true;
340
+ }
341
+ if (typeArguments.length > 0)
342
+ return false;
343
+ const declaration = lookup(reference.name, scope, 'type');
344
+ if (declaration?.kind === 'alias')
345
+ return membersOf(declaration.node, declaration.scope, depth + 1, out);
346
+ if (declaration?.kind !== 'interface')
347
+ return false;
348
+ for (const interfaceNode of declaration.nodes) {
349
+ for (const member of interfaceNode.body?.body ?? []) {
350
+ const key = memberKey(member);
351
+ if (key !== null && !out.has(key))
352
+ out.set(key, { type: member.typeAnnotation, scope: declaration.scope });
353
+ }
354
+ for (const heritage of interfaceNode.extends ?? []) {
355
+ const expression = heritage.expression;
356
+ if (expression?.type === 'Identifier') {
357
+ membersOf({ type: 'TSTypeReference', typeName: expression, typeArguments: heritage.typeArguments ?? heritage.typeParameters }, declaration.scope, depth + 1, out);
358
+ }
359
+ }
360
+ }
361
+ return true;
362
+ }
363
+ default:
364
+ return false;
365
+ }
366
+ }
367
+ /** Rule 9(b): the field names an annotation's object type declares, in declaration order. */
368
+ export function declaredFields(annotation, index, resolve) {
369
+ const members = new Map();
370
+ membersOf(annotation, { index, resolve, depth: 0 }, 0, members);
371
+ return [...members.keys()].slice(0, MAX_DECLARED_FIELDS);
372
+ }
373
+ /** The declared type of one member of an annotation's object type (a destructured parameter's property). */
374
+ export function memberType(annotation, key, index, resolve) {
375
+ const members = new Map();
376
+ membersOf(annotation, { index, resolve, depth: 0 }, 0, members);
377
+ const member = members.get(key);
378
+ return member?.type ? { annotation: member.type, index: member.scope.index, resolve: member.scope.resolve } : null;
379
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@haystackeditor/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "haystack verify: run your app with and without a change, and see what the change broke",
5
5
  "type": "module",
6
6
  "bin": {
7
- "haystack": "dist/index.js"
7
+ "haystack": "dist/index.js",
8
+ "haystack-capture-step": "dist/capture-step.js"
8
9
  },
9
10
  "exports": {
10
11
  "./next": {
@@ -54,6 +55,7 @@
54
55
  "chalk": "6.0.0",
55
56
  "commander": "15.0.0",
56
57
  "fast-glob": "3.3.3",
58
+ "parse5-sax-parser": "8.0.0",
57
59
  "pg": "8.23.0",
58
60
  "pg-connection-string": "2.14.0",
59
61
  "ws": "8.21.3",