@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
package/dist/telemetry/next.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
return withInertRuntime(config, 'it cannot instrument a build that reuses cached Turbopack modules; turn off experimental.turbopackFileSystemCacheForBuild to add it');
|
|
75
132
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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",
|