@pikku/core 0.12.95 → 0.12.96

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/CHANGELOG.md CHANGED
@@ -1,3 +1,46 @@
1
+ ## 0.12.96
2
+
3
+ ### Patch Changes
4
+
5
+ - 88629af: Say why a hot-reload import failed instead of only that it did.
6
+
7
+ The dev module runner caught every failure bare and returned `null`, and the reloader turned that into a single line: `Failed to import: … (keeping old code)`. Keeping the old code is the right call, but it leaves the running process disagreeing with the file on disk, and the only symptom is a function returning stale output while the editor shows the new source — `tsc` passes, every import resolves, and there is nothing anywhere to explain it.
8
+
9
+ `run` now returns `{ ok: true, exports }` or `{ ok: false, error }`, so the failure case cannot be read past, and the reloader prints the error's message and stack under the existing line. A failure matching pikku's own documented limitation — a file using top-level `await`, which the `cjs` emit cannot express — says so outright, because in that case nothing is wrong with the file and re-reading it will never reveal that.
10
+
11
+ - f1ccfe3: A step ladder reads as one paragraph, not a list of restatements
12
+
13
+ Every step prefixed its actor with `the `, named that actor again, and repeated
14
+ the phase keyword. A three-step run by one person said their name three times
15
+ and `Given` three times, only read as English when the persona key happened to
16
+ be a role noun, and never said who that person was — the fabric template's own
17
+ placeholder came out as `the nadia opens /app`.
18
+
19
+ ```
20
+ Given yasser (the founder) signs in
21
+ When yasser opens the dashboard
22
+ And sees the audit log
23
+ And nadia reviews the invite
24
+ ```
25
+
26
+ The article is gone: the actor key is the subject verbatim, so a persona named
27
+ after a person reads as that person. A repeated phase reads as `And`, the way
28
+ Gherkin has always written it. A step that continues both the phase and the
29
+ actor drops the repeated subject, because English drops a repeated subject in a
30
+ compound predicate — it takes both, since eliding across a phase change gives
31
+ `When opens the dashboard`, and a pronoun rather than a name would give `they
32
+ sees`, step templates being authored in the third person singular.
33
+
34
+ An actor is introduced once, by the persona's `jobTitle` — prose someone wrote
35
+ for a reader. `roles` is authorisation, so a persona whose only description is a
36
+ `reviewer` grant gets no introduction rather than one assembled out of grants.
37
+ A row carries `sentenceWithRole` alongside `sentence`, set only where an actor
38
+ is first named, so a renderer can offer the introduction as a toggle without
39
+ parsing a composed sentence back apart.
40
+
41
+ `{placeholder}` filling, the `#ordinal` lookup for repeated step names and an
42
+ actorless step reading as its description alone are all unchanged.
43
+
1
44
  ## 0.12.95
2
45
 
3
46
  ### Patch Changes
@@ -6,7 +6,7 @@ import { clearMiddlewareCache } from '../middleware-runner.js';
6
6
  import { clearPermissionsCache } from '../permissions.js';
7
7
  import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middleware-runner.js';
8
8
  import { httpRouter } from '../wirings/http/routers/http-router.js';
9
- import { createModuleRunner } from './module-runner.js';
9
+ import { createModuleRunner, isTopLevelAwaitLimitation, } from './module-runner.js';
10
10
  export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js';
11
11
  const isFunctionConfig = (value) => {
12
12
  return (typeof value === 'object' &&
@@ -40,6 +40,20 @@ const isWatchedTsFile = (filename) => {
40
40
  // Hidden files: editor/sed atomic-write temps must never trigger a reload.
41
41
  !basename(filename).startsWith('.'));
42
42
  };
43
+ /** Not every reload failure is a mistake in the file: pikku's reloader emits
44
+ * `cjs`, which has no way to express top-level `await`, so a perfectly valid
45
+ * module can fail here forever. Saying so outright saves the reader from
46
+ * hunting a bug that is not in their code. The stack is dropped in that case
47
+ * because it points into esbuild rather than at anything actionable. */
48
+ const reloadFailureReason = (error) => {
49
+ if (isTopLevelAwaitLimitation(error)) {
50
+ return (` ${error.message}\n` +
51
+ ' This is a pikku limitation, not a mistake in your file: the hot-reloader compiles to `cjs`, ' +
52
+ 'which cannot express top-level `await`. Move the awaited work into a function, or restart the ' +
53
+ 'dev server to pick the file up.');
54
+ }
55
+ return ` ${error.stack ?? error.message}`;
56
+ };
43
57
  export async function pikkuDevReloader(options) {
44
58
  const { srcDirectories, logger, pikkuDir = '.pikku' } = options;
45
59
  const absSrcDirs = srcDirectories.map((d) => resolve(d));
@@ -56,11 +70,17 @@ export async function pikkuDevReloader(options) {
56
70
  return;
57
71
  const compiledFile = await findCompiledFile(changedTsFile, srcDir, absPikkuDir);
58
72
  const importPath = compiledFile ?? changedTsFile;
59
- const mod = await moduleRunner.run(importPath);
60
- if (!mod) {
61
- logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`);
73
+ const result = await moduleRunner.run(importPath);
74
+ if (!result.ok) {
75
+ // Keeping the old code leaves the process disagreeing with the file on
76
+ // disk, and the only symptom is stale output from a function that looks
77
+ // correct in the editor — so the reason has to be printed here, where it
78
+ // is still known, rather than left for the developer to reconstruct.
79
+ logger.error(`Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)\n` +
80
+ reloadFailureReason(result.error));
62
81
  return;
63
82
  }
83
+ const mod = result.exports;
64
84
  // knowledge: decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md
65
85
  for (const [exportName, exportValue] of Object.entries(mod)) {
66
86
  if (!isFunctionConfig(exportValue))
@@ -1,10 +1,27 @@
1
+ /** The outcome of one run. A failure carries its error rather than collapsing
2
+ * to `null`: the caller keeps serving the previously-loaded code, so unless the
3
+ * reason travels with the failure the running process silently disagrees with
4
+ * the file on disk and nothing anywhere says why. */
5
+ export type PikkuModuleRunResult = {
6
+ ok: true;
7
+ exports: Record<string, unknown>;
8
+ } | {
9
+ ok: false;
10
+ error: Error;
11
+ };
1
12
  export interface PikkuModuleRunner {
2
13
  /** Run a user module by absolute path. Repeated runs of one path overwrite a
3
- * single registry slot. Returns `null` on failure so the caller can keep the
4
- * previously-loaded code. */
5
- run: (absPath: string) => Promise<Record<string, unknown> | null>;
14
+ * single registry slot. Failure is returned, not thrown, so the caller can
15
+ * keep the previously-loaded code — and the discriminant makes that case
16
+ * impossible to read past by accident. */
17
+ run: (absPath: string) => Promise<PikkuModuleRunResult>;
6
18
  evict: (absPath: string) => void;
7
19
  clear: () => void;
8
20
  readonly size: number;
9
21
  }
22
+ /** esbuild states pikku's one documented reload limitation only in the text of
23
+ * its transform error. Matching it is worth the fragility: the developer's file
24
+ * is correct, and no amount of re-reading it will reveal that the reloader —
25
+ * not the file — is what cannot cope. */
26
+ export declare const isTopLevelAwaitLimitation: (error: Error) => boolean;
10
27
  export declare const createModuleRunner: () => PikkuModuleRunner;
@@ -13,6 +13,11 @@ const loadTransform = async () => {
13
13
  transformSync = esbuild.transformSync;
14
14
  return transformSync;
15
15
  };
16
+ /** esbuild states pikku's one documented reload limitation only in the text of
17
+ * its transform error. Matching it is worth the fragility: the developer's file
18
+ * is correct, and no amount of re-reading it will reveal that the reloader —
19
+ * not the file — is what cannot cope. */
20
+ export const isTopLevelAwaitLimitation = (error) => /top-level await/i.test(error.message);
16
21
  export const createModuleRunner = () => {
17
22
  const registry = new Map();
18
23
  const run = async (filePath) => {
@@ -30,12 +35,20 @@ export const createModuleRunner = () => {
30
35
  const moduleObj = { exports: {} };
31
36
  fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath));
32
37
  registry.set(absPath, moduleObj.exports);
33
- return moduleObj.exports;
38
+ return { ok: true, exports: moduleObj.exports };
34
39
  }
35
- catch {
40
+ catch (thrown) {
36
41
  // A bad edit, or the one known limitation: a file using top-level
37
- // `await`, which cannot be emitted in `cjs` form.
38
- return null;
42
+ // `await`, which cannot be emitted in `cjs` form. Normalised to an
43
+ // `Error` so the caller always has a message and a stack to print
44
+ // without re-deriving them; a non-`Error` throw keeps its original value
45
+ // as the `cause`.
46
+ return {
47
+ ok: false,
48
+ error: thrown instanceof Error
49
+ ? thrown
50
+ : new Error(String(thrown), { cause: thrown }),
51
+ };
39
52
  }
40
53
  };
41
54
  return {
@@ -1,10 +1,32 @@
1
1
  import type { ScenarioStepPhase } from './scenario-step.types.js';
2
2
  export declare const renderStepTemplate: (template: string, input: unknown) => string;
3
- export declare const composeStepProse: ({ phase, description, template, input, actor, keywordWidth, }: {
3
+ export declare const composeStepProse: ({ phase, description, template, input, actor, actorRole, continuesPhase, continuesActor, keywordWidth, }: {
4
4
  phase: ScenarioStepPhase;
5
5
  description: string;
6
6
  template?: string;
7
7
  input?: unknown;
8
8
  actor?: string;
9
+ /**
10
+ * What this actor is, rendered as an apposition after their key — "yasser
11
+ * (the founder)". Only pass it where the actor has not been named yet: an
12
+ * ordinary run repeats one actor for a dozen steps, and repeating the role
13
+ * with them turns the one piece of context into the noise around it.
14
+ */
15
+ actorRole?: string;
16
+ /**
17
+ * This step repeats the phase of the one before it, so it reads as `And`
18
+ * rather than saying `Given` three times — the same thing Gherkin does.
19
+ */
20
+ continuesPhase?: boolean;
21
+ /**
22
+ * The step before this one had the same actor. Combined with `continuesPhase`
23
+ * the subject is dropped, because English drops a repeated subject in a
24
+ * compound predicate: "yasser opens the dashboard / and sees the audit log".
25
+ *
26
+ * It takes both. Dropping the subject across a phase change gives "When opens
27
+ * the dashboard", and a pronoun instead of a name would give "they sees",
28
+ * since step templates are authored in the third person singular.
29
+ */
30
+ continuesActor?: boolean;
9
31
  keywordWidth?: number;
10
32
  }) => string;
@@ -14,9 +14,13 @@ const formatValue = (value) => {
14
14
  }
15
15
  return String(value);
16
16
  };
17
- export const composeStepProse = ({ phase, description, template, input, actor, keywordWidth, }) => {
18
- const keyword = capitalise(phase);
19
- const subject = actor ? `the ${actor}` : '';
17
+ export const composeStepProse = ({ phase, description, template, input, actor, actorRole, continuesPhase, continuesActor, keywordWidth, }) => {
18
+ const keyword = capitalise(continuesPhase ? 'and' : phase);
19
+ // The actor key is the subject verbatim, with no article in front of it.
20
+ // "the ${actor}" only reads as English when the key happens to be a role
21
+ // noun — it turns a persona named after a person into "the nadia", which
22
+ // is the reporter quietly imposing a naming convention on the author.
23
+ const subject = continuesPhase && continuesActor ? '' : composeSubject(actor, actorRole);
20
24
  const rendered = template ? renderStepTemplate(template, input) : description;
21
25
  const sentence = [subject, rendered].filter(Boolean).join(' ');
22
26
  if (keywordWidth === undefined) {
@@ -24,4 +28,9 @@ export const composeStepProse = ({ phase, description, template, input, actor, k
24
28
  }
25
29
  return `${keyword.padEnd(keywordWidth)} ${sentence}`;
26
30
  };
31
+ const composeSubject = (actor, actorRole) => {
32
+ if (!actor)
33
+ return '';
34
+ return actorRole ? `${actor} (the ${actorRole})` : actor;
35
+ };
27
36
  const capitalise = (value) => value.charAt(0).toUpperCase() + value.slice(1);
@@ -35,6 +35,13 @@ export interface ScenarioArtifact {
35
35
  /** One step of a run, already joined to the prose that declared it. */
36
36
  export interface ScenarioStepRow {
37
37
  sentence: string;
38
+ /**
39
+ * The same sentence with the actor's role in it — "yasser (the founder)
40
+ * signs in". Set only on the step that first names each actor, and only
41
+ * when a persona declares a job title or a role, so a reader who wants the
42
+ * context picks this and one who wants the bare run picks `sentence`.
43
+ */
44
+ sentenceWithRole?: string;
38
45
  status: string;
39
46
  durationMs?: number;
40
47
  error?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.95",
3
+ "version": "0.12.96",
4
4
  "description": "The Pikku runtime — functions, wirings, services, middleware and types",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -215,6 +215,48 @@ describe('pikkuDevReloader', { concurrency: false }, () => {
215
215
  assert.deepEqual(await func.func({} as any, {}, {} as any), {
216
216
  working: true,
217
217
  })
218
+
219
+ // Serving the old code is only safe if the developer is told why; without
220
+ // the reason the sole symptom is a function that ignores the file on disk.
221
+ const failureLog = mockLogger
222
+ .getLogs()
223
+ .find((l) => l.message.includes('Failed to import'))
224
+ assert.ok(failureLog, 'Should log the failed import')
225
+ assert.ok(
226
+ failureLog!.message.includes('keeping old code'),
227
+ 'Should say the old code is still being served'
228
+ )
229
+ assert.match(failureLog!.message, /badFunc\.js/)
230
+ })
231
+
232
+ test('should name the top-level await limitation when a reload hits it', async (t) => {
233
+ if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
234
+
235
+ await writeFile(join(tmpDir, 'tlaFunc.ts'), '// initial')
236
+
237
+ reloader = await pikkuDevReloader({
238
+ srcDirectories: [tmpDir],
239
+ logger: mockLogger,
240
+ pikkuDir: tmpDir,
241
+ })
242
+
243
+ await writeFile(
244
+ join(tmpDir, 'tlaFunc.ts'),
245
+ `const config = await Promise.resolve({ ok: true })
246
+ export const tlaFunc = { func: async () => config }
247
+ // trigger ${Date.now()}`
248
+ )
249
+
250
+ await wait(300)
251
+
252
+ const failureLog = mockLogger
253
+ .getLogs()
254
+ .find((l) => l.message.includes('Failed to import'))
255
+ assert.ok(failureLog, 'Should log the failed import')
256
+ // The file is valid TypeScript; pointing at pikku's own `cjs` emit is the
257
+ // difference between a two-minute fix and an afternoon.
258
+ assert.match(failureLog!.message, /top-level `?await`?/i)
259
+ assert.match(failureLog!.message, /pikku limitation/i)
218
260
  })
219
261
 
220
262
  test('should ignore non-ts files, test files, and gen files', async (t) => {
@@ -9,7 +9,10 @@ import { clearChannelMiddlewareCache } from '../wirings/channel/channel-middlewa
9
9
  import { httpRouter } from '../wirings/http/routers/http-router.js'
10
10
  import type { Logger } from '../services/logger.js'
11
11
  import type { CorePikkuFunctionConfig } from '../function/functions.types.js'
12
- import { createModuleRunner } from './module-runner.js'
12
+ import {
13
+ createModuleRunner,
14
+ isTopLevelAwaitLimitation,
15
+ } from './module-runner.js'
13
16
 
14
17
  export { reloadGeneratedMeta, reconcileAddonRegistry } from './reload-meta.js'
15
18
 
@@ -63,6 +66,23 @@ const isWatchedTsFile = (filename: string): boolean => {
63
66
  )
64
67
  }
65
68
 
69
+ /** Not every reload failure is a mistake in the file: pikku's reloader emits
70
+ * `cjs`, which has no way to express top-level `await`, so a perfectly valid
71
+ * module can fail here forever. Saying so outright saves the reader from
72
+ * hunting a bug that is not in their code. The stack is dropped in that case
73
+ * because it points into esbuild rather than at anything actionable. */
74
+ const reloadFailureReason = (error: Error): string => {
75
+ if (isTopLevelAwaitLimitation(error)) {
76
+ return (
77
+ ` ${error.message}\n` +
78
+ ' This is a pikku limitation, not a mistake in your file: the hot-reloader compiles to `cjs`, ' +
79
+ 'which cannot express top-level `await`. Move the awaited work into a function, or restart the ' +
80
+ 'dev server to pick the file up.'
81
+ )
82
+ }
83
+ return ` ${error.stack ?? error.message}`
84
+ }
85
+
66
86
  export interface PikkuDevReloaderHandle {
67
87
  close: () => void
68
88
  /** Re-import every file changed since the last drain (post-codegen, once
@@ -96,13 +116,19 @@ export async function pikkuDevReloader(
96
116
  )
97
117
  const importPath = compiledFile ?? changedTsFile
98
118
 
99
- const mod = await moduleRunner.run(importPath)
100
- if (!mod) {
119
+ const result = await moduleRunner.run(importPath)
120
+ if (!result.ok) {
121
+ // Keeping the old code leaves the process disagreeing with the file on
122
+ // disk, and the only symptom is stale output from a function that looks
123
+ // correct in the editor — so the reason has to be printed here, where it
124
+ // is still known, rather than left for the developer to reconstruct.
101
125
  logger.error(
102
- `Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)`
126
+ `Failed to import: ${relative(process.cwd(), importPath)} (keeping old code)\n` +
127
+ reloadFailureReason(result.error)
103
128
  )
104
129
  return
105
130
  }
131
+ const mod = result.exports
106
132
 
107
133
  // knowledge: decisions/internals/hot-reload-writes-into-the-function-map-captured-at-startup.md
108
134
  for (const [exportName, exportValue] of Object.entries(mod)) {
@@ -8,7 +8,10 @@ import { join } from 'node:path'
8
8
  import { pathToFileURL } from 'node:url'
9
9
  import { tmpdir } from 'node:os'
10
10
 
11
- import { createModuleRunner } from './module-runner.js'
11
+ import {
12
+ createModuleRunner,
13
+ isTopLevelAwaitLimitation,
14
+ } from './module-runner.js'
12
15
 
13
16
  // A forced-GC hook without launching the process with a flag: on Bun use the
14
17
  // native collector; on Node flip --expose-gc on just long enough to grab `gc`.
@@ -56,9 +59,10 @@ describe('createModuleRunner', { concurrency: false }, () => {
56
59
  export const createTodo = { func: async (_s: any, d: Todo) => ({ id: d.id }) }`
57
60
  )
58
61
 
59
- const mod = await runner.run(file)
60
- assert.ok(mod)
61
- const createTodo = mod!.createTodo as {
62
+ const result = await runner.run(file)
63
+ assert.equal(result.ok, true)
64
+ const createTodo = (result as { exports: Record<string, unknown> }).exports
65
+ .createTodo as {
62
66
  func: (...a: any[]) => Promise<any>
63
67
  }
64
68
  assert.equal(typeof createTodo.func, 'function')
@@ -83,8 +87,9 @@ describe('createModuleRunner', { concurrency: false }, () => {
83
87
  wire('createTodo', createTodo)`
84
88
  )
85
89
 
86
- const mod = await runner.run(userFile)
87
- assert.ok(mod)
90
+ const result = await runner.run(userFile)
91
+ assert.equal(result.ok, true)
92
+ const mod = (result as { exports: Record<string, unknown> }).exports
88
93
 
89
94
  // Read the dependency through the same resolver the runner uses, so we
90
95
  // observe the exact instance the user module's `import` bound to (using a
@@ -103,26 +108,64 @@ describe('createModuleRunner', { concurrency: false }, () => {
103
108
 
104
109
  await writeFile(file, `export const value = { func: async () => 'v1' }`)
105
110
  const first = await runner.run(file)
106
- assert.equal(await (first!.value as any).func(), 'v1')
111
+ assert.equal(first.ok, true)
112
+ assert.equal(await ((first as any).exports.value as any).func(), 'v1')
107
113
 
108
114
  await writeFile(file, `export const value = { func: async () => 'v2' }`)
109
115
  const second = await runner.run(file)
110
- assert.equal(await (second!.value as any).func(), 'v2')
116
+ assert.equal(second.ok, true)
117
+ assert.equal(await ((second as any).exports.value as any).func(), 'v2')
111
118
 
112
119
  // Stable key: many reloads of one path never grow the registry.
113
120
  for (let i = 0; i < 20; i++) await runner.run(file)
114
121
  assert.equal(runner.size, 1)
115
122
  })
116
123
 
117
- test('returns null on a bad edit so the caller keeps old code', async () => {
124
+ test('reports a bad edit with its reason so the caller can say why', async () => {
118
125
  const runner = createModuleRunner()
119
126
  const file = join(tmpDir, 'broken.ts')
120
127
  await writeFile(
121
128
  file,
122
129
  `export const oops = { func: async () => ( } ] syntax`
123
130
  )
124
- const mod = await runner.run(file)
125
- assert.equal(mod, null)
131
+ const result = await runner.run(file)
132
+ assert.equal(result.ok, false)
133
+ // The caller keeps serving the old code, so this error is the only thing
134
+ // standing between the developer and an unexplained stale response.
135
+ const { error } = result as { error: Error }
136
+ assert.ok(error instanceof Error)
137
+ assert.match(error.message, /broken\.ts/)
138
+ assert.equal(isTopLevelAwaitLimitation(error), false)
139
+ })
140
+
141
+ test('names the top-level await limitation as such', async () => {
142
+ const runner = createModuleRunner()
143
+ const file = join(tmpDir, 'tla.ts')
144
+ await writeFile(
145
+ file,
146
+ `const config = await Promise.resolve({ ok: true })
147
+ export const load = { func: async () => config }`
148
+ )
149
+ const result = await runner.run(file)
150
+ assert.equal(result.ok, false)
151
+ // Nothing is wrong with this file — the `cjs` emit is what cannot take it,
152
+ // and the caller has to be able to tell the developer that.
153
+ assert.equal(
154
+ isTopLevelAwaitLimitation((result as { error: Error }).error),
155
+ true
156
+ )
157
+ })
158
+
159
+ test('a thrown non-Error still arrives as an Error carrying its value', async () => {
160
+ const runner = createModuleRunner()
161
+ const file = join(tmpDir, 'throws-a-string.ts')
162
+ await writeFile(file, `throw 'boom'`)
163
+ const result = await runner.run(file)
164
+ assert.equal(result.ok, false)
165
+ const { error } = result as { error: Error }
166
+ assert.ok(error instanceof Error)
167
+ assert.equal(error.message, 'boom')
168
+ assert.equal((error as { cause?: unknown }).cause, 'boom')
126
169
  })
127
170
 
128
171
  test('editing and reimporting a module 200x does not leak memory', async () => {
@@ -153,8 +196,8 @@ describe('createModuleRunner', { concurrency: false }, () => {
153
196
 
154
197
  for (let i = 1; i <= 200; i++) {
155
198
  await write(i)
156
- const mod = await runner.run(file)
157
- assert.ok(mod)
199
+ const result = await runner.run(file)
200
+ assert.equal(result.ok, true)
158
201
  }
159
202
  gc()
160
203
  const growth = heapUsedMb() - baseline
@@ -18,22 +18,35 @@ const loadTransform = async (): Promise<EsbuildTransform> => {
18
18
  return transformSync
19
19
  }
20
20
 
21
+ /** The outcome of one run. A failure carries its error rather than collapsing
22
+ * to `null`: the caller keeps serving the previously-loaded code, so unless the
23
+ * reason travels with the failure the running process silently disagrees with
24
+ * the file on disk and nothing anywhere says why. */
25
+ export type PikkuModuleRunResult =
26
+ { ok: true; exports: Record<string, unknown> } | { ok: false; error: Error }
27
+
21
28
  export interface PikkuModuleRunner {
22
29
  /** Run a user module by absolute path. Repeated runs of one path overwrite a
23
- * single registry slot. Returns `null` on failure so the caller can keep the
24
- * previously-loaded code. */
25
- run: (absPath: string) => Promise<Record<string, unknown> | null>
30
+ * single registry slot. Failure is returned, not thrown, so the caller can
31
+ * keep the previously-loaded code — and the discriminant makes that case
32
+ * impossible to read past by accident. */
33
+ run: (absPath: string) => Promise<PikkuModuleRunResult>
26
34
  evict: (absPath: string) => void
27
35
  clear: () => void
28
36
  readonly size: number
29
37
  }
30
38
 
39
+ /** esbuild states pikku's one documented reload limitation only in the text of
40
+ * its transform error. Matching it is worth the fragility: the developer's file
41
+ * is correct, and no amount of re-reading it will reveal that the reloader —
42
+ * not the file — is what cannot cope. */
43
+ export const isTopLevelAwaitLimitation = (error: Error): boolean =>
44
+ /top-level await/i.test(error.message)
45
+
31
46
  export const createModuleRunner = (): PikkuModuleRunner => {
32
47
  const registry = new Map<string, Record<string, unknown>>()
33
48
 
34
- const run = async (
35
- filePath: string
36
- ): Promise<Record<string, unknown> | null> => {
49
+ const run = async (filePath: string): Promise<PikkuModuleRunResult> => {
37
50
  const absPath = resolve(filePath)
38
51
  try {
39
52
  const transform = await loadTransform()
@@ -55,11 +68,20 @@ export const createModuleRunner = (): PikkuModuleRunner => {
55
68
  fn(require, moduleObj.exports, moduleObj, absPath, dirname(absPath))
56
69
 
57
70
  registry.set(absPath, moduleObj.exports)
58
- return moduleObj.exports
59
- } catch {
71
+ return { ok: true, exports: moduleObj.exports }
72
+ } catch (thrown) {
60
73
  // A bad edit, or the one known limitation: a file using top-level
61
- // `await`, which cannot be emitted in `cjs` form.
62
- return null
74
+ // `await`, which cannot be emitted in `cjs` form. Normalised to an
75
+ // `Error` so the caller always has a message and a stack to print
76
+ // without re-deriving them; a non-`Error` throw keeps its original value
77
+ // as the `cause`.
78
+ return {
79
+ ok: false,
80
+ error:
81
+ thrown instanceof Error
82
+ ? thrown
83
+ : new Error(String(thrown), { cause: thrown }),
84
+ }
63
85
  }
64
86
  }
65
87
 
@@ -222,10 +222,10 @@ describe('deriving intents from scenarios', () => {
222
222
  test('the prose comes through with its placeholders left open', () => {
223
223
  const [intent] = deriveIntents(workflows, functions)
224
224
  assert.deepEqual(intent!.steps, [
225
- 'Given the orgAdmin is signed in',
225
+ 'Given orgAdmin is signed in',
226
226
  // The scenario knows which address it invites. The user has to pick one.
227
- 'When the orgAdmin invites {email}',
228
- 'Then the orgAdmin sees the new member in the list',
227
+ 'When orgAdmin invites {email}',
228
+ 'Then orgAdmin sees the new member in the list',
229
229
  ])
230
230
  })
231
231
 
@@ -298,8 +298,8 @@ describe('deriving intents from scenarios', () => {
298
298
  )
299
299
 
300
300
  assert.deepEqual(intents[0]!.steps, [
301
- 'Given the orgAdmin is signed in',
302
- 'When the orgAdmin invites {email}',
301
+ 'Given orgAdmin is signed in',
302
+ 'When orgAdmin invites {email}',
303
303
  ])
304
304
  assert.deepEqual(intents[0]!.personas, ['orgAdmin'])
305
305
  })