@sanoma-ai/workflows 0.0.0-stage → 0.1.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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
@@ -0,0 +1,230 @@
1
+ import { Writable } from "node:stream";
2
+ import { supportCodeLibraryBuilder } from "@cucumber/cucumber";
3
+ import { type IRunOptions, type ISupportCodeLibrary, runCucumber } from "@cucumber/cucumber/api";
4
+ import { type CucumberExpression, type Node, NodeType } from "@cucumber/cucumber-expressions";
5
+ import { IdGenerator, type Pickle, type PickleStep, TestStepResultStatus } from "@cucumber/messages";
6
+ import { Query } from "@cucumber/query";
7
+
8
+ /*
9
+ * Cucumber, as Sanoma runs it: a support code library built from data (never from files it
10
+ * imports), and runs of feature files in this process, one after another, read back through
11
+ * Cucumber's own `Query` of its messages. Internal: `scenario.ts` says what the steps mean.
12
+ * Server-side only.
13
+ *
14
+ * Each step definition's code hands the step to the run's World (`SanomaWorld.step`), so one
15
+ * library serves every kind of run (compiling a scenario, checking a run, an operation's own
16
+ * test): only the World differs, and each run makes its own.
17
+ */
18
+
19
+ /** A parameter type the steps' expressions use, as Cucumber's `defineParameterType` takes it. */
20
+ export interface ParameterTypeSpec {
21
+ name: string;
22
+ regexp: RegExp | string | readonly (RegExp | string)[];
23
+ transformer: (...match: string[]) => unknown;
24
+ }
25
+
26
+ /** What one run does with its scenarios' steps. */
27
+ export interface SanomaWorld {
28
+ /** Called before the scenario's first step. */
29
+ begin(pickle: Pickle): void;
30
+ /** Runs one step: `expression` is the expression it matched, `values` its parameters. */
31
+ step(expression: string, values: unknown[], pickleStep: PickleStep): unknown;
32
+ }
33
+
34
+ /**
35
+ * Builds a support code library: the parameter types, then one step definition per expression,
36
+ * each handing its step to the World. Cucumber keeps one builder per process, so the library is
37
+ * built here, by hand, in one synchronous stretch (`reset`, register, `finalize`): building it
38
+ * again in the same process gives the same definitions, where a second `loadSupport` would
39
+ * import nothing new and find none.
40
+ */
41
+ export function buildLibrary(
42
+ parameterTypes: readonly ParameterTypeSpec[],
43
+ expressions: Iterable<string>,
44
+ ): ISupportCodeLibrary {
45
+ const builder = supportCodeLibraryBuilder;
46
+ builder.reset(process.cwd(), IdGenerator.uuid(), {
47
+ requireModules: [],
48
+ requirePaths: [],
49
+ importPaths: [],
50
+ loaders: [],
51
+ });
52
+ const { defineParameterType, defineStep, Before, BeforeStep } = builder.methods;
53
+ for (const { name, regexp, transformer } of parameterTypes) {
54
+ defineParameterType({ name, regexp: regexp as RegExp, transformer, useForSnippets: false });
55
+ }
56
+ for (const expression of expressions) defineStep(expression, stepCode(expression));
57
+ Before(function (this: Current, { pickle }) {
58
+ if (this.run.skips(pickle)) return "skipped";
59
+ this.begin(pickle);
60
+ return undefined;
61
+ });
62
+ BeforeStep(function (this: Current, { pickleStep }) {
63
+ this.current = pickleStep;
64
+ });
65
+ const library = builder.finalize();
66
+ // Cucumber checks a definition's code's `length` against what it passes: the expression's
67
+ // parameters, then the step's doc string or table, or else a callback. Its own parse of each
68
+ // expression says how many parameters it has.
69
+ for (const { code, expression } of library.stepDefinitions) {
70
+ Object.defineProperty(code, "length", {
71
+ value: countParameters((expression as CucumberExpression).ast) + 1,
72
+ });
73
+ }
74
+ return library;
75
+ }
76
+
77
+ /** The World as the library's code sees it: the run's, with the run's state and the step being run. */
78
+ type Current = SanomaWorld & { current: PickleStep; run: RunState };
79
+
80
+ /** What a run's Worlds share: which scenarios it skips, and where a bug in a step's code goes. */
81
+ interface RunState {
82
+ skips(pickle: Pickle): boolean;
83
+ caught(err: unknown): void;
84
+ }
85
+
86
+ const countParameters = (node: Node): number =>
87
+ node.type === NodeType.parameter ? 1 : (node.nodes ?? []).reduce((n, child) => n + countParameters(child), 0);
88
+
89
+ /**
90
+ * A step definition's code. Cucumber passes the expression's values, then the step's doc string
91
+ * or table when it has one, then a callback: with one more argument than the parameters it took
92
+ * the callback of a step with no argument (the callback interface), and with two more, the
93
+ * argument of a step that has one (the promise interface). The World reads the argument from
94
+ * the pickle step, so the code answers both.
95
+ */
96
+ function stepCode(expression: string) {
97
+ const code = function (this: Current, ...args: unknown[]) {
98
+ const parameters = code.length - 1;
99
+ const result = Promise.resolve()
100
+ .then(() => this.step(expression, args.slice(0, parameters), this.current))
101
+ .catch((err: unknown) => {
102
+ this.run.caught(err);
103
+ throw err;
104
+ });
105
+ if (args.length > parameters + 1) return result;
106
+ const done = args.at(-1) as (error: unknown, result?: unknown) => void;
107
+ result.then(
108
+ (value) => done(null, value),
109
+ (err: unknown) => done(err),
110
+ );
111
+ return undefined;
112
+ };
113
+ return code;
114
+ }
115
+
116
+ /** How one step ended. */
117
+ export interface StepResult {
118
+ status: TestStepResultStatus;
119
+ /** Why it failed: its error's message. */
120
+ message?: string;
121
+ /** The expressions that match it: none for an undefined step, more than one for an ambiguous one. */
122
+ matches: string[];
123
+ }
124
+
125
+ /** What a run read: Cucumber's `Query` of its messages, each file's text, and the files Gherkin could not parse. */
126
+ export interface Report {
127
+ query: Query;
128
+ /** Each file's text, by its path relative to the run's `cwd`. */
129
+ texts: Map<string, string>;
130
+ /** Files Gherkin could not parse; none of the run's scenarios ran when there are any. */
131
+ parseErrors: { uri: string; line?: number; message: string }[];
132
+ }
133
+
134
+ /** How each step of a run ended, by its pickle step's id; a step of a pickle that did not run has none. */
135
+ export function stepResults({ query }: Report): Map<string, StepResult> {
136
+ const results = new Map<string, StepResult>();
137
+ for (const finished of query.findAllTestStepFinished()) {
138
+ const testStep = query.findTestStepBy(finished);
139
+ if (!testStep?.pickleStepId) continue;
140
+ const { status, message, exception } = finished.testStepResult;
141
+ const why = exception?.message ?? message;
142
+ results.set(testStep.pickleStepId, {
143
+ status,
144
+ matches: query.findStepDefinitionsBy(testStep).map((d) => d.pattern.source),
145
+ ...(why !== undefined && { message: why }),
146
+ });
147
+ }
148
+ return results;
149
+ }
150
+
151
+ /** A pickle's line: its examples row's, or its scenario's. */
152
+ export const lineOf = (report: Report, pickle: Pickle) => report.query.findLocationOf(pickle)?.line ?? 1;
153
+
154
+ /** A stream that drops what it is given: Cucumber's formatters and logger write nowhere. */
155
+ const sink = () =>
156
+ new Writable({
157
+ write(_chunk, _encoding, callback) {
158
+ callback();
159
+ },
160
+ });
161
+
162
+ /**
163
+ * A path as a glob that matches it alone: Cucumber globs each path it is given, and reads one
164
+ * whose name starts with `@` as a list of paths. Brackets escape what they can; brace
165
+ * expansion reads braces and commas before brackets, so those (and backslashes) match any
166
+ * character instead.
167
+ */
168
+ const literal = (path: string) => path.replaceAll(/[*?[\]()+@]/g, "[$&]").replaceAll(/[{},\\]/g, "?");
169
+
170
+ /**
171
+ * Runs the feature files `files` (relative to `cwd`) with the library, serially, in this
172
+ * process, and reads back what happened. `line`, with a single file, runs only the scenario or
173
+ * examples row at that line. `makeWorld` makes each scenario's World, given the report so far
174
+ * (every file and pickle, read before the first scenario starts). A dry run matches the steps
175
+ * and runs none. A step's error that is a bug in code (`isBug`) is rethrown once the run is
176
+ * over, rather than reported as the step's failure.
177
+ */
178
+ export async function runFeatures(
179
+ library: ISupportCodeLibrary,
180
+ makeWorld: (report: Report) => SanomaWorld,
181
+ {
182
+ cwd,
183
+ files,
184
+ line,
185
+ dryRun = false,
186
+ isBug,
187
+ }: { cwd: string; files: string[]; line?: number; dryRun?: boolean; isBug: (err: unknown) => boolean },
188
+ ): Promise<Report> {
189
+ const report: Report = { query: new Query(), texts: new Map(), parseErrors: [] };
190
+ let bug: unknown;
191
+ const run: RunState = {
192
+ skips: (pickle) => line !== undefined && lineOf(report, pickle) !== line,
193
+ caught: (err) => {
194
+ if (isBug(err)) bug ??= err;
195
+ },
196
+ };
197
+ // Cucumber makes a scenario's World with `new`: a constructor that returns an object gives that object.
198
+ function World() {
199
+ return Object.assign(makeWorld(report), { run });
200
+ }
201
+ const configuration: IRunOptions = {
202
+ sources: { defaultDialect: "en", paths: files.map(literal), names: [], tagExpression: "", order: "defined" },
203
+ support: { ...library, World } as ISupportCodeLibrary,
204
+ runtime: {
205
+ dryRun,
206
+ failFast: false,
207
+ filterStacktraces: true,
208
+ parallel: 0,
209
+ retry: 0,
210
+ retryTagFilter: "",
211
+ strict: true,
212
+ worldParameters: {},
213
+ },
214
+ // Cucumber always formats to stdout, here a sink: `rerun` is the formatter that does least.
215
+ formats: { stdout: "rerun", files: {}, publish: false, options: {} },
216
+ };
217
+ await runCucumber(configuration, { cwd, stdout: sink(), stderr: sink(), env: {} }, (envelope) => {
218
+ report.query.update(envelope);
219
+ const { source, parseError } = envelope;
220
+ if (source) report.texts.set(source.uri, source.data);
221
+ if (parseError) {
222
+ const { uri = "", location } = parseError.source;
223
+ report.parseErrors.push({ uri, ...(location && { line: location.line }), message: parseError.message });
224
+ }
225
+ });
226
+ if (bug !== undefined) throw bug;
227
+ return report;
228
+ }
229
+
230
+ export { TestStepResultStatus as StepStatus };
@@ -0,0 +1,409 @@
1
+ import { basename, dirname, join } from "node:path";
2
+ import type { Node } from "./ast.ts";
3
+ import { inside, nearestDir, RELATIVE } from "./paths.ts";
4
+
5
+ // The one definition of what a data file may hold, over an ESTree program from oxc: the reader
6
+ // (`resources.ts`), `lintResources` (`lint.ts`) and the oxlint plugin (`plugin.ts`) all
7
+ // read a file through `readDataFile`. Type imports only, so the plugin, which oxlint hands its
8
+ // own tree, never loads oxc-parser.
9
+
10
+ /** Something a data file may not hold, at UTF-16 offsets into its text. */
11
+ export interface DataFileProblem {
12
+ start: number;
13
+ end: number;
14
+ message: string;
15
+ }
16
+
17
+ /** A name in a data file that refers to a declared resource: one imported from a data file, or exported by this one. */
18
+ export class Reference {
19
+ readonly name: string;
20
+ constructor(name: string) {
21
+ this.name = name;
22
+ }
23
+ }
24
+
25
+ /** A value in a data file: a literal, or a reference to a declared resource. */
26
+ export type DataValue = null | boolean | number | string | Reference | DataValue[] | { [field: string]: DataValue };
27
+
28
+ /** An `import { name } from "…"`: of a connector's constructors (`from "…/resources"`), or of another data file's resource. */
29
+ export interface DataFileImport {
30
+ kind: "constructors" | "file";
31
+ /** The specifier as written. */
32
+ source: string;
33
+ /** The name the other module exports. */
34
+ imported: string;
35
+ /** The name it has in this file. */
36
+ local: string;
37
+ start: number;
38
+ }
39
+
40
+ /** An `export const name = vendor.type({ … })`. */
41
+ export interface DataFileExport {
42
+ name: string;
43
+ /** The local name of the constructors it calls (`github`). */
44
+ vendor: string;
45
+ type: string;
46
+ desired: { [field: string]: DataValue };
47
+ /** The `export const` statement. */
48
+ start: number;
49
+ end: number;
50
+ }
51
+
52
+ export interface DataFile {
53
+ imports: DataFileImport[];
54
+ /** Each `export const` whose value is in the subset. */
55
+ exports: DataFileExport[];
56
+ /** Every name an `export const` declares, its value in the subset or not. */
57
+ declared: string[];
58
+ problems: DataFileProblem[];
59
+ }
60
+
61
+ /** A connector's constructors entry: a package specifier that ends in `/resources`. */
62
+ const CONSTRUCTORS = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*\/resources$/;
63
+
64
+ const SHAPE =
65
+ "a data file holds only imports, `export const <name> = <vendor>.<type>({ … })` and `export default [ … ]`";
66
+ const IMPORTS =
67
+ "import resource constructors from a connector's resources entry " +
68
+ '(`import { github } from "@sanoma-ai/connector-github/resources"`) ' +
69
+ 'and resources from other data files (`import { site } from "./github.ts"`)';
70
+ const VALUES = "write a string, number, boolean, null, object or array, or name a declared resource";
71
+
72
+ /**
73
+ * Reads a data file's program without running it: its imports, each `export const` resource
74
+ * with its fields evaluated, and everything outside the data-file subset as a problem that says
75
+ * what to write instead.
76
+ *
77
+ * The subset: named imports, from a connector's `…/resources` entry or a `.ts` data file;
78
+ * `export const <name> = <vendor>.<type>({ … })`, one per statement, its argument built of
79
+ * strings (template literals without `${}` too), numbers, booleans, null, objects and arrays,
80
+ * and names of resources this file declares or imports; and `export default [a, b]`.
81
+ *
82
+ * `root` is the directory relative imports must stay in: by default the nearest `resources/`
83
+ * directory above `filename`, else the file's own.
84
+ */
85
+ export function readDataFile(
86
+ program: Node,
87
+ filename: string,
88
+ root: string = nearestDir(filename, ["resources"]),
89
+ ): DataFile {
90
+ const problems: DataFileProblem[] = [];
91
+ const fail = (node: { start: number; end: number }, message: string) =>
92
+ problems.push({ start: node.start, end: node.end, message });
93
+ const imports: DataFileImport[] = [];
94
+ const exports: DataFileExport[] = [];
95
+ // Names a refused import or declaration binds: reported once, where they are bound.
96
+ const refused = new Set<string>();
97
+
98
+ // Imports and top-level names first, each once: imports are hoisted, and a reference may name
99
+ // a resource declared further down. An unexported const is refused where it is declared, not
100
+ // again where it is used.
101
+ const bound = new Set<string>();
102
+ const bind = (id: Node) => {
103
+ if (bound.has(id.name)) fail(id, `${id.name} is declared twice in this file: give each its own name`);
104
+ bound.add(id.name);
105
+ };
106
+ const declared = new Set<string>();
107
+ for (const s of program.body) {
108
+ if (s.type === "ImportDeclaration") {
109
+ for (const sp of s.specifiers) bind(sp.local);
110
+ readImport(s);
111
+ continue;
112
+ }
113
+ const decl = s.type === "ExportNamedDeclaration" ? s.declaration : s;
114
+ if (decl?.type !== "VariableDeclaration") continue;
115
+ for (const d of decl.declarations) {
116
+ if (d.id.type !== "Identifier") continue;
117
+ bind(d.id);
118
+ (decl === s ? refused : declared).add(d.id.name);
119
+ }
120
+ }
121
+
122
+ for (const s of program.body) {
123
+ switch (s.type) {
124
+ case "ImportDeclaration":
125
+ break;
126
+ case "ExportNamedDeclaration":
127
+ readExport(s);
128
+ break;
129
+ case "ExportDefaultDeclaration":
130
+ readDefault(s);
131
+ break;
132
+ case "ExportAllDeclaration":
133
+ fail(s, `export * is not allowed in a data file: ${SHAPE}`);
134
+ break;
135
+ case "VariableDeclaration": {
136
+ const name = s.declarations[0]?.id?.name;
137
+ fail(
138
+ s,
139
+ name === undefined
140
+ ? `an unexported declaration is not allowed in a data file: ${SHAPE}`
141
+ : `\`${s.kind} ${name}\` is not allowed in a data file: export it as a resource, \`export const ${name} = <vendor>.<type>({ … })\`, or write its value where it is used`,
142
+ );
143
+ break;
144
+ }
145
+ default:
146
+ fail(s, `${describe(s)} is not allowed in a data file: ${SHAPE}`);
147
+ }
148
+ }
149
+
150
+ function readImport(s: Node) {
151
+ const spec: string = s.source.value;
152
+ if (s.importKind === "type") {
153
+ fail(s, "`import type` is not allowed in a data file: it holds values only, and the constructors type them");
154
+ return;
155
+ }
156
+ const refusal = whyRefused(spec);
157
+ if (refusal) {
158
+ fail(s.source, `import "${spec}" is not allowed in a data file: ${refusal}`);
159
+ for (const sp of s.specifiers) refused.add(sp.local.name);
160
+ return;
161
+ }
162
+ if (!s.specifiers.length) {
163
+ fail(s, `import "${spec}" is not allowed in a data file: it imports nothing, and a data file runs no code`);
164
+ return;
165
+ }
166
+ for (const sp of s.specifiers) {
167
+ if (sp.type !== "ImportSpecifier") {
168
+ const how = sp.type === "ImportNamespaceSpecifier" ? `import * as ${sp.local.name}` : `import ${sp.local.name}`;
169
+ fail(sp, `${how} is not allowed in a data file: import the names you use, \`import { name } from "${spec}"\``);
170
+ continue;
171
+ }
172
+ if (sp.importKind === "type") {
173
+ fail(sp, "`type` imports are not allowed in a data file: it holds values only, and the constructors type them");
174
+ continue;
175
+ }
176
+ const imported: string = sp.imported.name ?? sp.imported.value;
177
+ const kind = RELATIVE.test(spec) ? "file" : "constructors";
178
+ imports.push({ kind, source: spec, imported, local: sp.local.name, start: sp.start });
179
+ }
180
+ }
181
+
182
+ /** Why an import's specifier is not allowed in a data file, or undefined when it is. */
183
+ function whyRefused(spec: string): string | undefined {
184
+ if (!RELATIVE.test(spec)) return CONSTRUCTORS.test(spec) ? undefined : IMPORTS;
185
+ if (!spec.endsWith(".ts")) return "name the data file with its `.ts` extension";
186
+ if (!inside(root, join(dirname(filename), spec))) {
187
+ return `it reaches outside ${basename(root)}/; import only other data files`;
188
+ }
189
+ return undefined;
190
+ }
191
+
192
+ function readExport(s: Node) {
193
+ const decl = s.declaration;
194
+ if (!decl) {
195
+ fail(
196
+ s,
197
+ s.source
198
+ ? `export … from "${s.source.value}" is not allowed in a data file: import what you reference, and export only resources declared here`
199
+ : "export { … } is not allowed in a data file: export each resource where it is declared, `export const name = <vendor>.<type>({ … })`",
200
+ );
201
+ return;
202
+ }
203
+ if (decl.type !== "VariableDeclaration") {
204
+ fail(s, `${describe(decl)} is not allowed in a data file: ${SHAPE}`);
205
+ return;
206
+ }
207
+ if (decl.kind !== "const") {
208
+ fail(s, `\`export ${decl.kind}\` is not allowed in a data file: use \`export const\``);
209
+ return;
210
+ }
211
+ if (decl.declarations.length !== 1) {
212
+ fail(s, "declare one resource per `export const`");
213
+ return;
214
+ }
215
+ const [d] = decl.declarations;
216
+ if (d.id.type !== "Identifier") {
217
+ fail(d.id, "an `export const` names one resource: `export const name = <vendor>.<type>({ … })`");
218
+ return;
219
+ }
220
+ const name: string = d.id.name;
221
+ // A second declaration of the name was reported where it is bound.
222
+ if (exports.some((e) => e.name === name)) return;
223
+ const init = d.init;
224
+ const callee = init?.type === "CallExpression" ? init.callee : undefined;
225
+ if (
226
+ callee?.type !== "MemberExpression" ||
227
+ callee.computed ||
228
+ callee.object.type !== "Identifier" ||
229
+ callee.property.type !== "Identifier"
230
+ ) {
231
+ fail(
232
+ init ?? d,
233
+ `export const ${name} must be a resource constructor call, \`<vendor>.<type>({ … })\`, with <vendor> imported from a connector's resources entry`,
234
+ );
235
+ return;
236
+ }
237
+ const vendor: string = callee.object.name;
238
+ const type: string = callee.property.name;
239
+ if (refused.has(vendor)) return;
240
+ if (imports.find((i) => i.local === vendor)?.kind !== "constructors") {
241
+ fail(
242
+ callee.object,
243
+ `${vendor} is not a connector's resource constructors: import it from the connector's resources entry, \`import { ${vendor} } from "@sanoma-ai/connector-${vendor}/resources"\``,
244
+ );
245
+ return;
246
+ }
247
+ const [arg] = init.arguments;
248
+ if (init.arguments.length !== 1 || arg.type !== "ObjectExpression") {
249
+ fail(init, `${vendor}.${type} takes one object literal: \`${vendor}.${type}({ … })\``);
250
+ return;
251
+ }
252
+ const desired = value(arg);
253
+ if (desired !== undefined) {
254
+ exports.push({ name, vendor, type, desired: desired as DataFileExport["desired"], start: s.start, end: s.end });
255
+ }
256
+ }
257
+
258
+ function readDefault(s: Node) {
259
+ const list = s.declaration;
260
+ if (list.type !== "ArrayExpression") {
261
+ fail(list, "export default must list this file's resources: `export default [a, b]`");
262
+ return;
263
+ }
264
+ for (const el of list.elements) {
265
+ if (el?.type !== "Identifier") {
266
+ fail(el ?? list, "export default must list this file's resources by name: `export default [a, b]`");
267
+ } else if (!declared.has(el.name)) {
268
+ fail(el, `${el.name} is not declared in this file: export default lists this file's resources`);
269
+ }
270
+ }
271
+ }
272
+
273
+ /** The value an expression stands for, or undefined (with a problem) when the subset has none. */
274
+ function value(node: Node): DataValue | undefined {
275
+ switch (node.type) {
276
+ case "Literal":
277
+ if (node.regex) return refuse(node, "a regular expression is not allowed in a data file: write it as a string");
278
+ if (node.bigint !== undefined && node.bigint !== null)
279
+ return refuse(node, "a bigint is not allowed in a data file: write a number, or a string");
280
+ return node.value as null | boolean | number | string;
281
+ case "TemplateLiteral":
282
+ if (node.expressions.length) {
283
+ return refuse(node, "a template with `${…}` is not allowed in a data file: write the string out");
284
+ }
285
+ return node.quasis[0].value.cooked as string;
286
+ case "UnaryExpression":
287
+ if (node.operator === "-" && node.argument.type === "Literal" && typeof node.argument.value === "number") {
288
+ return -node.argument.value;
289
+ }
290
+ return refuse(node, `\`${node.operator}\` is not allowed in a data file: write the value out`);
291
+ case "ArrayExpression": {
292
+ const items: DataValue[] = [];
293
+ let ok = true;
294
+ for (const el of node.elements) {
295
+ const item =
296
+ el === null
297
+ ? refuse(node, "an empty array slot is not allowed in a data file: write null")
298
+ : el.type === "SpreadElement"
299
+ ? refuse(el, "a spread is not allowed in a data file: write the items out, so the reader sees each")
300
+ : value(el);
301
+ if (item === undefined) ok = false;
302
+ else items.push(item);
303
+ }
304
+ return ok ? items : undefined;
305
+ }
306
+ case "ObjectExpression": {
307
+ const fields = new Map<string, DataValue>();
308
+ let ok = true;
309
+ for (const p of node.properties) {
310
+ const field = property(p);
311
+ if (field && fields.has(field[0])) fail(p, `${field[0]} is given twice: give each field once`);
312
+ if (!field || fields.has(field[0])) ok = false;
313
+ else fields.set(...field);
314
+ }
315
+ // fromEntries makes each field an own property, even one named `__proto__`.
316
+ return ok ? Object.fromEntries(fields) : undefined;
317
+ }
318
+ case "Identifier":
319
+ return reference(node);
320
+ case "MemberExpression": {
321
+ let base = node;
322
+ while (base.type === "MemberExpression") base = base.object;
323
+ if (base.type === "Identifier" && base.name === "process") {
324
+ return refuse(
325
+ node,
326
+ "process is not allowed in a data file: it is read without running, so it cannot read the environment; write the value out, and leave secrets to drivers",
327
+ );
328
+ }
329
+ return refuse(
330
+ node,
331
+ "member access is not allowed in a data file: write the value out, or name the resource itself (`site`), not one of its fields",
332
+ );
333
+ }
334
+ case "CallExpression":
335
+ return refuse(
336
+ node,
337
+ node.callee.type === "MemberExpression" &&
338
+ node.callee.object.type === "Identifier" &&
339
+ imports.some((i) => i.kind === "constructors" && i.local === node.callee.object.name)
340
+ ? "a resource is declared at the top of a data file: give it its own `export const` and name it here"
341
+ : "a call is not allowed in a data file: write the value out; the reader never runs code",
342
+ );
343
+ case "NewExpression":
344
+ return refuse(node, "`new` is not allowed in a data file: write the value out (a date as an ISO 8601 string)");
345
+ case "ArrowFunctionExpression":
346
+ case "FunctionExpression":
347
+ return refuse(
348
+ node,
349
+ "a function is not allowed in a data file: a field holds a value, and code belongs in a workflow",
350
+ );
351
+ case "TSAsExpression":
352
+ case "TSSatisfiesExpression":
353
+ case "TSTypeAssertion":
354
+ case "TSNonNullExpression":
355
+ return refuse(
356
+ node,
357
+ "a type assertion is not allowed in a data file: leave it out; the constructor types the value",
358
+ );
359
+ default:
360
+ return refuse(node, `${describe(node)} is not allowed in a data file: ${VALUES}`);
361
+ }
362
+ }
363
+
364
+ function property(p: Node): [string, DataValue] | undefined {
365
+ if (p.type === "SpreadElement") {
366
+ return refuse(p, "a spread is not allowed in a data file: write the fields out, so the reader sees each");
367
+ }
368
+ if (p.kind !== "init" || p.method) {
369
+ return refuse(p, "a method or accessor is not allowed in a data file: a field holds a value");
370
+ }
371
+ if (p.computed) return refuse(p.key, "a computed key is not allowed in a data file: write the field's name");
372
+ const key = p.key.type === "Identifier" ? p.key.name : p.key.type === "Literal" ? String(p.key.value) : undefined;
373
+ if (key === undefined) return refuse(p.key, "write the field's name, or a string");
374
+ const v = value(p.value);
375
+ return v === undefined ? undefined : [key, v];
376
+ }
377
+
378
+ function reference(node: Node): Reference | undefined {
379
+ const name: string = node.name;
380
+ if (name === "undefined")
381
+ return refuse(node, "undefined is not allowed in a data file: leave the field out, or write null");
382
+ const imported = imports.find((i) => i.local === name);
383
+ if (imported?.kind === "constructors") {
384
+ return refuse(node, `${name} is a connector's resource constructors, not a resource: name a declared resource`);
385
+ }
386
+ if (imported || declared.has(name)) return new Reference(name);
387
+ if (refused.has(name)) return undefined;
388
+ return refuse(
389
+ node,
390
+ `${name} is not a resource this file declares or imports from a data file: write the value out, or import the resource`,
391
+ );
392
+ }
393
+
394
+ function refuse(node: { start: number; end: number }, message: string): undefined {
395
+ fail(node, message);
396
+ return undefined;
397
+ }
398
+
399
+ return { imports, exports, declared: [...declared], problems };
400
+ }
401
+
402
+ /** A node's kind, for a message: `a function declaration`, `an if statement`. */
403
+ function describe(node: Node): string {
404
+ const words = String(node.type)
405
+ .replace(/^TS/, "TypeScript ")
406
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
407
+ .toLowerCase();
408
+ return `${/^[aeiou]/.test(words) ? "an" : "a"} ${words}`;
409
+ }