@arki/dot 0.1.4 → 0.2.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 (42) hide show
  1. package/README.md +57 -0
  2. package/dist/cli/index.d.ts +5 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +21 -1
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/render-graph.d.ts +36 -0
  7. package/dist/cli/render-graph.d.ts.map +1 -0
  8. package/dist/cli/render-graph.js +70 -0
  9. package/dist/cli/render-graph.js.map +1 -0
  10. package/dist/define-app.d.ts +10 -0
  11. package/dist/define-app.d.ts.map +1 -1
  12. package/dist/define-app.js +2 -0
  13. package/dist/define-app.js.map +1 -1
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/kernel/app-instance.d.ts +2 -0
  19. package/dist/kernel/app-instance.d.ts.map +1 -1
  20. package/dist/kernel/app-instance.js +67 -17
  21. package/dist/kernel/app-instance.js.map +1 -1
  22. package/dist/lifecycle.d.ts +2 -0
  23. package/dist/lifecycle.d.ts.map +1 -1
  24. package/dist/lifecycle.js +2 -0
  25. package/dist/lifecycle.js.map +1 -1
  26. package/dist/signals.d.ts +65 -0
  27. package/dist/signals.d.ts.map +1 -0
  28. package/dist/signals.js +97 -0
  29. package/dist/signals.js.map +1 -0
  30. package/dist/test-harness.d.ts +75 -1
  31. package/dist/test-harness.d.ts.map +1 -1
  32. package/dist/test-harness.js +71 -0
  33. package/dist/test-harness.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/index.ts +36 -2
  36. package/src/cli/render-graph.ts +88 -0
  37. package/src/define-app.ts +13 -0
  38. package/src/index.ts +5 -2
  39. package/src/kernel/app-instance.ts +113 -51
  40. package/src/lifecycle.ts +2 -0
  41. package/src/signals.ts +134 -0
  42. package/src/test-harness.ts +140 -1
@@ -25,7 +25,7 @@
25
25
  * });
26
26
  */
27
27
  import type { DotApp, DotAppBuilder } from './define-app.js';
28
- import type { AnyPip, EmptyShape, ServiceRecord } from './pip-contract.js';
28
+ import type { AnyPip, EmptyShape, Pip, ServiceRecord, Token } from './pip-contract.js';
29
29
  export type TestAppOptions = {
30
30
  /** App name used in the manifest. Defaults to `'test-app'`. */
31
31
  name?: string;
@@ -42,4 +42,78 @@ export declare function testApp<TServices extends ServiceRecord = EmptyShape>(pi
42
42
  * calling `app.dispose()` when finished.
43
43
  */
44
44
  export declare function bootTestApp<TServices extends ServiceRecord = EmptyShape>(pips?: readonly AnyPip[], options?: TestAppOptions): Promise<DotApp<TServices>>;
45
+ /**
46
+ * Rest-tuple guard for {@link TestPipBuilder.boot} — the same trick the app
47
+ * builder's `UseGuard` uses. While any need is still unprovided, `boot()`
48
+ * demands an impossible second argument, so the call site fails with
49
+ * "Expected 2 arguments, but got 1" and the error payload names the
50
+ * missing wire keys and their types.
51
+ */
52
+ type TestPipBootGuard<TRemaining extends ServiceRecord> = keyof TRemaining extends never ? readonly [] : readonly [
53
+ needs: {
54
+ readonly 'DOT-TEST: pip needs still unprovided — call .provide() for each': {
55
+ readonly [K in keyof TRemaining]: TRemaining[K];
56
+ };
57
+ }
58
+ ];
59
+ /**
60
+ * Typed unit-test builder for a single pip — see {@link testPip}.
61
+ *
62
+ * `TRemaining` tracks the wire keys not yet covered by `.provide()`;
63
+ * `TServices` accumulates what the booted app will expose (the fakes plus
64
+ * the pip's own provides).
65
+ */
66
+ export type TestPipBuilder<TRemaining extends ServiceRecord, TServices extends ServiceRecord> = {
67
+ /**
68
+ * Satisfy one need directly with a fake. Token form — the token names
69
+ * the wire key and carries the type:
70
+ *
71
+ * ```ts
72
+ * testPip(reports).provide(Db, fakeDb)
73
+ * ```
74
+ */
75
+ provide<K extends keyof TRemaining & string, T extends TRemaining[K]>(token: Token<T, K>, value: T): TestPipBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, T>>>;
76
+ /**
77
+ * Satisfy one need directly with a fake. Key form — for anonymous
78
+ * `service<T>()` needs, where the wire key is the property name:
79
+ *
80
+ * ```ts
81
+ * testPip(billing).provide('db', fakeDb)
82
+ * ```
83
+ */
84
+ provide<K extends keyof TRemaining & string>(key: K, value: TRemaining[K]): TestPipBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, TRemaining[K]>>>;
85
+ /** Boot the pip against the provided fakes. Compile error until every need is provided. */
86
+ boot(...guard: TestPipBootGuard<TRemaining>): Promise<DotApp<TServices>>;
87
+ /** Boot + start. Same guard as {@link TestPipBuilder.boot}. */
88
+ start(...guard: TestPipBootGuard<TRemaining>): Promise<DotApp<TServices>>;
89
+ };
90
+ /**
91
+ * Unit-test a single pip with **typed overrides** — the compile-checked
92
+ * counterpart to {@link testApp}'s erased arrays.
93
+ *
94
+ * Each `.provide()` satisfies one of the pip's needs directly (no real
95
+ * provider pip, no dependency chain) and removes it from the builder's
96
+ * remaining-needs type. `boot()` only compiles once every need is covered,
97
+ * and a fake of the wrong shape fails at the `.provide()` call site:
98
+ *
99
+ * ```ts
100
+ * import { testPip } from '@arki/dot/test-harness';
101
+ *
102
+ * const app = await testPip(catalog)
103
+ * .provide(Db, fakeDb) // token need
104
+ * .provide('cache', fakeKv) // anonymous need — wire key is the alias
105
+ * .boot();
106
+ *
107
+ * expect(app.services.catalog.list()).toEqual([]);
108
+ * await app.dispose();
109
+ * ```
110
+ *
111
+ * `service.lazy<T>()` needs accept either a plain `T` fake (the kernel
112
+ * lifts it) or a `Lazy<T>` handle (`lazyOf(value)` is handy here).
113
+ * Lifecycle semantics are the real kernel's — the fakes are published by a
114
+ * synthetic first pip, so reverse-order teardown and lazy auto-dispose
115
+ * behave exactly as in production.
116
+ */
117
+ export declare function testPip<TNeeds extends ServiceRecord, TProvides extends ServiceRecord>(pip: Pip<TNeeds, TProvides>, options?: TestAppOptions): TestPipBuilder<TNeeds, TProvides>;
118
+ export {};
45
119
  //# sourceMappingURL=test-harness.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"test-harness.d.ts","sourceRoot":"","sources":["../src/test-harness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG3E,MAAM,MAAM,cAAc,GAAG;IAC3B,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC5C,CAAC;AAWF;;;GAGG;AACH,wBAAgB,OAAO,CAAC,SAAS,SAAS,aAAa,GAAG,UAAU,EAClE,IAAI,GAAE,SAAS,MAAM,EAAO,EAC5B,OAAO,GAAE,cAAmB,GAC3B,aAAa,CAAC,SAAS,CAAC,CAM1B;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,SAAS,SAAS,aAAa,GAAG,UAAU,EAC5E,IAAI,GAAE,SAAS,MAAM,EAAO,EAC5B,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAE5B"}
1
+ {"version":3,"file":"test-harness.d.ts","sourceRoot":"","sources":["../src/test-harness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAGvF,MAAM,MAAM,cAAc,GAAG;IAC3B,+DAA+D;IAC/D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,wDAAwD;IACxD,MAAM,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC5C,CAAC;AAWF;;;GAGG;AACH,wBAAgB,OAAO,CAAC,SAAS,SAAS,aAAa,GAAG,UAAU,EAClE,IAAI,GAAE,SAAS,MAAM,EAAO,EAC5B,OAAO,GAAE,cAAmB,GAC3B,aAAa,CAAC,SAAS,CAAC,CAM1B;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,SAAS,SAAS,aAAa,GAAG,UAAU,EAC5E,IAAI,GAAE,SAAS,MAAM,EAAO,EAC5B,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAE5B;AAED;;;;;;GAMG;AACH,KAAK,gBAAgB,CAAC,UAAU,SAAS,aAAa,IAAI,MAAM,UAAU,SAAS,KAAK,GACpF,SAAS,EAAE,GACX,SAAS;IACP,KAAK,EAAE;QACL,QAAQ,CAAC,iEAAiE,EAAE;YAC1E,QAAQ,EAAE,CAAC,IAAI,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,CAAC;SAChD,CAAC;KACH;CACF,CAAC;AAEN;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,CAAC,UAAU,SAAS,aAAa,EAAE,SAAS,SAAS,aAAa,IAAI;IAC9F;;;;;;;OAOG;IACH,OAAO,CAAC,CAAC,SAAS,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EAClE,KAAK,EAAE,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAClB,KAAK,EAAE,CAAC,GACP,cAAc,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3E;;;;;;;OAOG;IACH,OAAO,CAAC,CAAC,SAAS,MAAM,UAAU,GAAG,MAAM,EACzC,GAAG,EAAE,CAAC,EACN,KAAK,EAAE,UAAU,CAAC,CAAC,CAAC,GACnB,cAAc,CAAC,IAAI,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACvF,2FAA2F;IAC3F,IAAI,CAAC,GAAG,KAAK,EAAE,gBAAgB,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;IACzE,+DAA+D;IAC/D,KAAK,CAAC,GAAG,KAAK,EAAE,gBAAgB,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC;CAC3E,CAAC;AASF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,OAAO,CAAC,MAAM,SAAS,aAAa,EAAE,SAAS,SAAS,aAAa,EACnF,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,EAC3B,OAAO,GAAE,cAAmB,GAC3B,cAAc,CAAC,MAAM,EAAE,SAAS,CAAC,CAInC"}
@@ -43,4 +43,75 @@ export function testApp(pips = [], options = {}) {
43
43
  export async function bootTestApp(pips = [], options = {}) {
44
44
  return testApp(pips, options).boot();
45
45
  }
46
+ /**
47
+ * Unit-test a single pip with **typed overrides** — the compile-checked
48
+ * counterpart to {@link testApp}'s erased arrays.
49
+ *
50
+ * Each `.provide()` satisfies one of the pip's needs directly (no real
51
+ * provider pip, no dependency chain) and removes it from the builder's
52
+ * remaining-needs type. `boot()` only compiles once every need is covered,
53
+ * and a fake of the wrong shape fails at the `.provide()` call site:
54
+ *
55
+ * ```ts
56
+ * import { testPip } from '@arki/dot/test-harness';
57
+ *
58
+ * const app = await testPip(catalog)
59
+ * .provide(Db, fakeDb) // token need
60
+ * .provide('cache', fakeKv) // anonymous need — wire key is the alias
61
+ * .boot();
62
+ *
63
+ * expect(app.services.catalog.list()).toEqual([]);
64
+ * await app.dispose();
65
+ * ```
66
+ *
67
+ * `service.lazy<T>()` needs accept either a plain `T` fake (the kernel
68
+ * lifts it) or a `Lazy<T>` handle (`lazyOf(value)` is handy here).
69
+ * Lifecycle semantics are the real kernel's — the fakes are published by a
70
+ * synthetic first pip, so reverse-order teardown and lazy auto-dispose
71
+ * behave exactly as in production.
72
+ */
73
+ export function testPip(pip, options = {}) {
74
+ // Erasure seam — same boundary as `makeBuilder` in define-app.ts: the
75
+ // runtime impl is untyped, the type parameters do all the guarding.
76
+ return makeTestPipBuilder({ pip: pip, fakes: {}, options });
77
+ }
78
+ /**
79
+ * Erased implementation behind {@link testPip}. Mirrors `makeBuilder` in
80
+ * `define-app.ts`: the runtime is untyped, the single cast at the return
81
+ * is the seam, and the type parameters do all the guarding.
82
+ */
83
+ function makeTestPipBuilder(state) {
84
+ const bootApp = async () => {
85
+ const pips = [];
86
+ if (Object.keys(state.fakes).length > 0) {
87
+ const fakes = { ...state.fakes };
88
+ pips.push({
89
+ name: `test:fakes(${state.pip.name})`,
90
+ needs: {},
91
+ renames: {},
92
+ hooks: { boot: () => fakes },
93
+ });
94
+ }
95
+ pips.push(state.pip);
96
+ return testApp(pips, {
97
+ name: state.options.name ?? `test:${state.pip.name}`,
98
+ ...(state.options.config === undefined ? {} : { config: state.options.config }),
99
+ }).boot();
100
+ };
101
+ const impl = {
102
+ provide(tokenOrKey, value) {
103
+ const key = typeof tokenOrKey === 'string' ? tokenOrKey : tokenOrKey.key;
104
+ return makeTestPipBuilder({ ...state, fakes: { ...state.fakes, [key]: value } });
105
+ },
106
+ async boot(..._guard) {
107
+ return bootApp();
108
+ },
109
+ async start(..._guard) {
110
+ const app = await bootApp();
111
+ await app.start();
112
+ return app;
113
+ },
114
+ };
115
+ return impl;
116
+ }
46
117
  //# sourceMappingURL=test-harness.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"test-harness.js","sourceRoot":"","sources":["../src/test-harness.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAkB5C;;;GAGG;AACH,MAAM,UAAU,OAAO,CACrB,OAA0B,EAAE,EAC5B,UAA0B,EAAE;IAE5B,IAAI,OAAO,GAAY,SAAS,CAAC,OAAO,CAAC,IAAI,IAAI,UAAU,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACzF,KAAK,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;QACrB,OAAO,GAAI,OAAwB,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC;IACD,OAAO,OAAmC,CAAC;AAC7C,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,OAA0B,EAAE,EAC5B,UAA0B,EAAE;IAE5B,OAAO,OAAO,CAAY,IAAI,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;AAClD,CAAC"}
1
+ {"version":3,"file":"test-harness.js","sourceRoot":"","sources":["../src/test-harness.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arki/dot",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "TypeScript-first application composition framework for the ARKI package family — lifecycle-aware pips, deterministic boot order, manifest/diagnostics envelopes, and an agent-native CLI. A DOT app is the sum of its pips.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli/index.ts CHANGED
@@ -33,6 +33,7 @@ import { runNew } from './new.js';
33
33
  import { probeObservability } from './observability-probe.js';
34
34
  import { renderDoctor } from './render-doctor.js';
35
35
  import { renderExplain } from './render-explain.js';
36
+ import { renderGraph } from './render-graph.js';
36
37
 
37
38
  const debugCli = createDebugLogger('arki:dot:cli');
38
39
 
@@ -57,6 +58,10 @@ Common options:
57
58
  --app <path> Path to the app entry file (default: discovers
58
59
  ./dot.config.ts, ./src/app.ts, or ./app.ts)
59
60
  --cwd <dir> Working directory (default: current)
61
+ --graph Emit the pip graph as Mermaid flowchart source
62
+ instead of the standard output. explain shows
63
+ declaration (= boot) order; doctor shows the
64
+ wiring observed during boot. Composes with --json.
60
65
 
61
66
  \`doctor\` options:
62
67
  --observability Also probe whether an OpenTelemetry SDK is
@@ -89,6 +94,8 @@ export type CliArgs = {
89
94
  force?: boolean;
90
95
  /** `--observability` (only honored by `doctor`). */
91
96
  observability?: boolean;
97
+ /** `--graph` (honored by `explain` and `doctor`). */
98
+ graph?: boolean;
92
99
  };
93
100
 
94
101
  /**
@@ -152,6 +159,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
152
159
  'dry-run': { type: 'boolean', default: false },
153
160
  force: { type: 'boolean', default: false },
154
161
  observability: { type: 'boolean', default: false },
162
+ graph: { type: 'boolean', default: false },
155
163
  },
156
164
  });
157
165
  } catch (err) {
@@ -174,6 +182,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
174
182
  'dry-run'?: boolean;
175
183
  force?: boolean;
176
184
  observability?: boolean;
185
+ graph?: boolean;
177
186
  };
178
187
 
179
188
  if (values.help) command = 'help';
@@ -205,6 +214,7 @@ export function parseArgs(argv: readonly string[]): CliArgs {
205
214
  dryRun: values['dry-run'] ?? false,
206
215
  force: values.force ?? false,
207
216
  observability: values.observability ?? false,
217
+ graph: values.graph ?? false,
208
218
  };
209
219
  }
210
220
 
@@ -220,7 +230,7 @@ async function loadApp(args: CliArgs): Promise<DiscoveredApp> {
220
230
  */
221
231
  export async function runExplain(
222
232
  discovered: DiscoveredApp,
223
- opts: { json: boolean; out?: (line: string) => void; now?: () => Date },
233
+ opts: { json: boolean; graph?: boolean; out?: (line: string) => void; now?: () => Date },
224
234
  ): Promise<DotCliEnvelope<unknown>> {
225
235
  let configured: DotApp<Record<string, unknown>> | DotAppConfigured<Record<string, unknown>>;
226
236
  try {
@@ -236,6 +246,12 @@ export async function runExplain(
236
246
  throw wrapLifecycleError(err, 'configure');
237
247
  }
238
248
 
249
+ if (opts.graph === true) {
250
+ return renderGraph(
251
+ { manifest: configured.manifest, command: 'explain' },
252
+ { json: opts.json, out: opts.out, now: opts.now },
253
+ );
254
+ }
239
255
  return renderExplain({ manifest: configured.manifest }, { json: opts.json, out: opts.out, now: opts.now });
240
256
  }
241
257
 
@@ -249,6 +265,8 @@ type DoctorRunOptions = {
249
265
  * present. Default `false`.
250
266
  */
251
267
  observability?: boolean;
268
+ /** When `true`, emit the pip graph (Mermaid) instead of diagnostics. */
269
+ graph?: boolean;
252
270
  };
253
271
 
254
272
  /**
@@ -268,6 +286,12 @@ export async function runDoctor(
268
286
  // Already-booted app: just read diagnostics, don't touch lifecycle.
269
287
  if (!guards.isDotAppBuilder(discovered) && !guards.isDotAppConfigured(discovered)) {
270
288
  const app = discovered as DotApp<Record<string, unknown>>;
289
+ if (opts.graph === true) {
290
+ return renderGraph(
291
+ { manifest: app.manifest, command: 'doctor' },
292
+ { json: opts.json, out: opts.out, now: opts.now },
293
+ );
294
+ }
271
295
  const diagnostics = applyObservabilityProbe(app.diagnostics, opts.observability ?? false);
272
296
  return renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
273
297
  }
@@ -297,6 +321,16 @@ export async function runDoctor(
297
321
  }
298
322
 
299
323
  try {
324
+ if (opts.graph === true) {
325
+ // Post-boot manifest carries the observed wiring edges; after a boot
326
+ // failure it carries the edges recorded up to the failing pip.
327
+ const manifest = bootedApp ? bootedApp.manifest : configured.manifest;
328
+ const graphEnvelope = renderGraph(
329
+ { manifest, command: 'doctor' },
330
+ { json: opts.json, out: opts.out, now: opts.now },
331
+ );
332
+ return bootThrew ? { ...graphEnvelope, status: 'failure' } : graphEnvelope;
333
+ }
300
334
  const rawDiagnostics = bootedApp ? bootedApp.diagnostics : configured.diagnostics;
301
335
  const diagnostics = applyObservabilityProbe(rawDiagnostics, opts.observability ?? false);
302
336
  const envelope = renderDoctor({ diagnostics }, { json: opts.json, out: opts.out, now: opts.now });
@@ -450,7 +484,7 @@ export async function main(options: MainOptions): Promise<number> {
450
484
 
451
485
  try {
452
486
  const discovered = await loadApp(args);
453
- const opts = { json: args.json, out: stdout, now: nowFactory };
487
+ const opts = { json: args.json, graph: args.graph, out: stdout, now: nowFactory };
454
488
  let envelope: DotCliEnvelope<unknown>;
455
489
 
456
490
  if (args.command === 'explain') {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Renderer for `dot explain --graph` / `dot doctor --graph`.
3
+ *
4
+ * Emits the app's pip graph as [Mermaid](https://mermaid.js.org) `flowchart`
5
+ * source — paste-able into GitHub markdown, docs, and mermaid.live.
6
+ *
7
+ * The nodes are the pips in declaration order (which IS boot order in v2 —
8
+ * the numbering makes that visible); the edges are the manifest's
9
+ * **observed** dependency edges, recorded by the kernel when a pip's need
10
+ * was satisfied during boot. `explain` never boots, so its graph shows
11
+ * declaration order with whatever edges configure-time metadata declared;
12
+ * `doctor` boots, so its graph shows the real wiring.
13
+ */
14
+
15
+ import type { DotAppManifest } from '../manifest.js';
16
+ import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
17
+
18
+ /** Envelope payload for graph output. */
19
+ export type GraphData = {
20
+ format: 'mermaid';
21
+ /** Mermaid `flowchart` source. */
22
+ source: string;
23
+ };
24
+
25
+ /** Escape a pip name for use inside a quoted Mermaid node label. */
26
+ function escapeLabel(name: string): string {
27
+ return name.replaceAll('"', '#quot;');
28
+ }
29
+
30
+ /**
31
+ * Build Mermaid `flowchart` source from a manifest. Deterministic: node
32
+ * ids follow declaration order, edges follow manifest order.
33
+ */
34
+ export function buildMermaidGraph(manifest: DotAppManifest): string {
35
+ const lines: string[] = ['flowchart TD'];
36
+ const idByPip = new Map<string, string>();
37
+
38
+ for (const [index, pip] of manifest.pips.entries()) {
39
+ const id = `p${index.toString()}`;
40
+ idByPip.set(pip.name, id);
41
+ const order = (index + 1).toString();
42
+ const version = pip.version === undefined ? '' : `@${pip.version}`;
43
+ lines.push(` ${id}["${order} · ${escapeLabel(pip.name)}${escapeLabel(version)}"]`);
44
+ }
45
+
46
+ for (const edge of manifest.dependencies) {
47
+ const from = idByPip.get(edge.from);
48
+ const to = idByPip.get(edge.to);
49
+ // Edges referencing unknown pips would be a kernel bug — skip rather
50
+ // than emit invalid Mermaid.
51
+ if (from === undefined || to === undefined) continue;
52
+ lines.push(` ${from} -->|${edge.kind}| ${to}`);
53
+ }
54
+
55
+ return `${lines.join('\n')}\n`;
56
+ }
57
+
58
+ /**
59
+ * Render the graph output. Plain mode prints raw Mermaid source (pipe it
60
+ * straight into a markdown code fence); `--json` wraps it in the standard
61
+ * CLI envelope under `data.source`.
62
+ */
63
+ export function renderGraph(
64
+ source: { manifest: DotAppManifest; command: 'explain' | 'doctor' },
65
+ opts: RenderOptions,
66
+ ): DotCliEnvelope<GraphData> {
67
+ const mermaid = buildMermaidGraph(source.manifest);
68
+ const nowFactory = opts.now ?? (() => new Date());
69
+ const envelope: DotCliEnvelope<GraphData> = {
70
+ status: 'success',
71
+ command: source.command,
72
+ generatedAt: nowFactory().toISOString(),
73
+ data: { format: 'mermaid', source: mermaid },
74
+ errors: [],
75
+ };
76
+
77
+ const out =
78
+ opts.out ??
79
+ ((line: string) => {
80
+ process.stdout.write(line);
81
+ });
82
+ if (opts.json) {
83
+ out(`${JSON.stringify(envelope, null, 2)}\n`);
84
+ } else {
85
+ out(mermaid);
86
+ }
87
+ return envelope;
88
+ }
package/src/define-app.ts CHANGED
@@ -176,6 +176,7 @@ type BuilderState = {
176
176
  pips: AnyPip[];
177
177
  config?: Readonly<Record<string, unknown>>;
178
178
  observers?: readonly DotLifecycleObserver[];
179
+ hookTimeoutMs?: number;
179
180
  };
180
181
 
181
182
  /**
@@ -204,6 +205,16 @@ export function defineApp(
204
205
  * `configured.subscribe(...)` or `app.subscribe(...)`.
205
206
  */
206
207
  observers?: readonly DotLifecycleObserver[];
208
+ /**
209
+ * Watchdog budget (ms) for each async hook invocation (`boot`, `start`,
210
+ * `stop`, `dispose` — `configure` is sync). A hook exceeding the budget
211
+ * fails with `DOT_LIFECYCLE_E015` naming the pip and hook, and the
212
+ * kernel applies its normal failure rules (boot rollback, teardown
213
+ * aggregation). The hook's promise itself cannot be cancelled — the
214
+ * watchdog makes the hang *visible*, it does not kill it. Default:
215
+ * no watchdog.
216
+ */
217
+ hookTimeoutMs?: number;
207
218
  } = {},
208
219
  ): DotAppBuilder<EmptyShape> {
209
220
  const state: BuilderState = {
@@ -212,6 +223,7 @@ export function defineApp(
212
223
  pips: [],
213
224
  config: options.config,
214
225
  observers: options.observers,
226
+ hookTimeoutMs: options.hookTimeoutMs,
215
227
  };
216
228
  return makeBuilder<EmptyShape>(state);
217
229
  }
@@ -223,6 +235,7 @@ function buildImpl(state: BuilderState): DotAppImpl {
223
235
  pips: state.pips,
224
236
  config: state.config,
225
237
  observers: state.observers,
238
+ hookTimeoutMs: state.hookTimeoutMs,
226
239
  });
227
240
  }
228
241
 
package/src/index.ts CHANGED
@@ -80,8 +80,11 @@ export type {
80
80
  export { renderTimeline } from './timeline.js';
81
81
  export type { RenderTimelineOptions } from './timeline.js';
82
82
 
83
- export { testApp, bootTestApp } from './test-harness.js';
84
- export type { TestAppOptions } from './test-harness.js';
83
+ export { testApp, bootTestApp, testPip } from './test-harness.js';
84
+ export type { TestAppOptions, TestPipBuilder } from './test-harness.js';
85
+
86
+ export { hookSignals } from './signals.js';
87
+ export type { HookSignalsOptions, SignalTarget } from './signals.js';
85
88
 
86
89
  // Task 9b: CLI envelope type is exported so adapter packages can produce the
87
90
  // same shape from related tooling (release-tooling, pip scaffolds, etc.).